authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 19:57:42-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 03:20:19-07:00
log0aad32984f17248dab49bcb4b36a5342a8fcf036
tree2a890e4d21a13f59fc4818d56d582c29f374aafe
parent77c1c97d315c0c636926287befc0f1202290cef7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: iCloud sync, updater, custom tag art, spelling, accessibility, server notebooks, and the unsupported-content pass

- iCloud Drive (#30): conflict versions merge back by replaying their changes, reads and writes are coordinated, edits publish on a pause and when the app loses focus; Section::page_at reads earlier page revisions. - Updater (#33): signed builds published by tools/release.py into dated folders; the app checks, verifies, stages, and restarts into updates. - Custom tag art (#47) in the notebook's hidden .snowbound folder, 15 Snowbound symbols, and OneNote-faithful fallbacks. - Spell checking on every platform (#48); the UI kit's accessibility tree and keyboard navigation (#20); Open Notebook from Server (#46). - Unsupported content (#25): table tags, printouts, object-only outlines, cross-table selections, and equation joins behave as in OneNote 2010. - Toolbar audit (#49), command palette go-to mode, dialogs top-centre, hover submenus, tab-drag fixes, dimmed dialogs. - Sync: replicas keyed by location and identity, duplicate sections tolerated, unreadable or busy sections set aside instead of failing the notebook, password-protected sections reported plainly, iCloud placeholders. - Linux: a single executable that writes its own desktop entry and icon, with Install and Uninstall; xdg-toplevel-icon on KDE. - Mac OS X 10.6: pipe-based wakers fix multi-second SMB stalls; readdir alignment, autorelease pools, the textured window line, and a Snow Leopard screenshot mode. - iOS: On My iPhone as a folder, spelling corrections, iCloud location. - ISC license. Assisted-by: claude-opus-5.5

619 files changed, 27459 insertions(+), 1520 deletions(-)

Cargo.lock+145-1
...@@ -451,6 +451,12 @@ version = "0.22.1"...@@ -451,6 +451,12 @@ version = "0.22.1"
451source = "registry+https://github.com/rust-lang/crates.io-index"451source = "registry+https://github.com/rust-lang/crates.io-index"
452checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"452checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
453453
454[[package]]
455name = "base64"
456version = "0.23.1"
457source = "registry+https://github.com/rust-lang/crates.io-index"
458checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
459
454[[package]]460[[package]]
455name = "bit-set"461name = "bit-set"
456version = "0.10.0"462version = "0.10.0"
...@@ -964,6 +970,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"...@@ -964,6 +970,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
964name = "draw"970name = "draw"
965version = "0.1.0"971version = "0.1.0"
966dependencies = [972dependencies = [
973 "base64 0.22.1",
967 "bytemuck",974 "bytemuck",
968 "image",975 "image",
969 "linebender_resource_handle",976 "linebender_resource_handle",
...@@ -1237,6 +1244,17 @@ dependencies = [...@@ -1237,6 +1244,17 @@ dependencies = [
1237 "windows-link",1244 "windows-link",
1238]1245]
12391246
1247[[package]]
1248name = "getrandom"
1249version = "0.2.17"
1250source = "registry+https://github.com/rust-lang/crates.io-index"
1251checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1252dependencies = [
1253 "cfg-if",
1254 "libc",
1255 "wasi",
1256]
1257
1240[[package]]1258[[package]]
1241name = "getrandom"1259name = "getrandom"
1242version = "0.3.4"1260version = "0.3.4"
...@@ -1400,6 +1418,22 @@ dependencies = [...@@ -1400,6 +1418,22 @@ dependencies = [
1400 "digest",1418 "digest",
1401]1419]
14021420
1421[[package]]
1422name = "http"
1423version = "1.5.0"
1424source = "registry+https://github.com/rust-lang/crates.io-index"
1425checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
1426dependencies = [
1427 "bytes",
1428 "itoa",
1429]
1430
1431[[package]]
1432name = "httparse"
1433version = "1.10.1"
1434source = "registry+https://github.com/rust-lang/crates.io-index"
1435checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1436
1403[[package]]1437[[package]]
1404name = "hybrid-array"1438name = "hybrid-array"
1405version = "0.4.14"1439version = "0.4.14"
...@@ -1885,6 +1919,8 @@ dependencies = [...@@ -1885,6 +1919,8 @@ dependencies = [
1885 "canvas",1919 "canvas",
1886 "draw",1920 "draw",
1887 "notebook",1921 "notebook",
1922 "objc2 0.5.2",
1923 "objc2-foundation 0.2.2",
1888 "onestore",1924 "onestore",
1889 "parley",1925 "parley",
1890 "pollster",1926 "pollster",
...@@ -1990,6 +2026,7 @@ name = "notebook"...@@ -1990,6 +2026,7 @@ name = "notebook"
1990version = "0.1.0"2026version = "0.1.0"
1991dependencies = [2027dependencies = [
1992 "libc",2028 "libc",
2029 "nix",
1993 "onestore",2030 "onestore",
1994 "rusqlite",2031 "rusqlite",
1995 "serde",2032 "serde",
...@@ -2361,7 +2398,7 @@ name = "onestore"...@@ -2361,7 +2398,7 @@ name = "onestore"
2361version = "0.1.0"2398version = "0.1.0"
2362dependencies = [2399dependencies = [
2363 "aes",2400 "aes",
2364 "base64",2401 "base64 0.22.1",
2365 "bumpalo",2402 "bumpalo",
2366 "cbc",2403 "cbc",
2367 "getrandom 0.4.3",2404 "getrandom 0.4.3",
...@@ -2849,6 +2886,20 @@ dependencies = [...@@ -2849,6 +2886,20 @@ dependencies = [
2849 "bytemuck",2886 "bytemuck",
2850]2887]
28512888
2889[[package]]
2890name = "ring"
2891version = "0.17.14"
2892source = "registry+https://github.com/rust-lang/crates.io-index"
2893checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2894dependencies = [
2895 "cc",
2896 "cfg-if",
2897 "getrandom 0.2.17",
2898 "libc",
2899 "untrusted",
2900 "windows-sys 0.52.0",
2901]
2902
2852[[package]]2903[[package]]
2853name = "roxmltree"2904name = "roxmltree"
2854version = "0.21.1"2905version = "0.21.1"
...@@ -2924,6 +2975,41 @@ dependencies = [...@@ -2924,6 +2975,41 @@ dependencies = [
2924 "windows-sys 0.61.2",2975 "windows-sys 0.61.2",
2925]2976]
29262977
2978[[package]]
2979name = "rustls"
2980version = "0.23.45"
2981source = "registry+https://github.com/rust-lang/crates.io-index"
2982checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
2983dependencies = [
2984 "log",
2985 "once_cell",
2986 "ring",
2987 "rustls-pki-types",
2988 "rustls-webpki",
2989 "subtle",
2990 "zeroize",
2991]
2992
2993[[package]]
2994name = "rustls-pki-types"
2995version = "1.15.1"
2996source = "registry+https://github.com/rust-lang/crates.io-index"
2997checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
2998dependencies = [
2999 "zeroize",
3000]
3001
3002[[package]]
3003name = "rustls-webpki"
3004version = "0.103.15"
3005source = "registry+https://github.com/rust-lang/crates.io-index"
3006checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
3007dependencies = [
3008 "ring",
3009 "rustls-pki-types",
3010 "untrusted",
3011]
3012
2927[[package]]3013[[package]]
2928name = "rustversion"3014name = "rustversion"
2929version = "1.0.23"3015version = "1.0.23"
...@@ -3232,8 +3318,10 @@ name = "snowbound"...@@ -3232,8 +3318,10 @@ name = "snowbound"
3232version = "0.1.0"3318version = "0.1.0"
3233dependencies = [3319dependencies = [
3234 "accesskit",3320 "accesskit",
3321 "accesskit_consumer",
3235 "accesskit_winit",3322 "accesskit_winit",
3236 "arboard",3323 "arboard",
3324 "block2 0.5.1",
3237 "canvas",3325 "canvas",
3238 "draw",3326 "draw",
3239 "fontique",3327 "fontique",
...@@ -3247,11 +3335,16 @@ dependencies = [...@@ -3247,11 +3335,16 @@ dependencies = [
3247 "parley",3335 "parley",
3248 "png",3336 "png",
3249 "pollster",3337 "pollster",
3338 "ring",
3250 "sctk-adwaita",3339 "sctk-adwaita",
3251 "serde",3340 "serde",
3252 "serde_json",3341 "serde_json",
3253 "smithay-clipboard",3342 "smithay-clipboard",
3254 "ui",3343 "ui",
3344 "ureq",
3345 "wayland-backend",
3346 "wayland-client",
3347 "wayland-protocols",
3255 "wgpu",3348 "wgpu",
3256 "winit",3349 "winit",
3257 "zbus",3350 "zbus",
...@@ -3616,6 +3709,8 @@ dependencies = [...@@ -3616,6 +3709,8 @@ dependencies = [
3616name = "ui"3709name = "ui"
3617version = "0.1.0"3710version = "0.1.0"
3618dependencies = [3711dependencies = [
3712 "accesskit",
3713 "accesskit_consumer",
3619 "draw",3714 "draw",
3620 "nucleo-matcher",3715 "nucleo-matcher",
3621 "parley",3716 "parley",
...@@ -3653,6 +3748,40 @@ dependencies = [...@@ -3653,6 +3748,40 @@ dependencies = [
3653 "ctutils",3748 "ctutils",
3654]3749]
36553750
3751[[package]]
3752name = "untrusted"
3753version = "0.9.0"
3754source = "registry+https://github.com/rust-lang/crates.io-index"
3755checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3756
3757[[package]]
3758name = "ureq"
3759version = "3.4.2"
3760source = "registry+https://github.com/rust-lang/crates.io-index"
3761checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
3762dependencies = [
3763 "base64 0.23.1",
3764 "log",
3765 "percent-encoding",
3766 "rustls",
3767 "rustls-pki-types",
3768 "ureq-proto",
3769 "utf8-zero",
3770 "webpki-roots",
3771]
3772
3773[[package]]
3774name = "ureq-proto"
3775version = "0.6.4"
3776source = "registry+https://github.com/rust-lang/crates.io-index"
3777checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
3778dependencies = [
3779 "base64 0.23.1",
3780 "http",
3781 "httparse",
3782 "log",
3783]
3784
3656[[package]]3785[[package]]
3657name = "usvg"3786name = "usvg"
3658version = "0.48.1"3787version = "0.48.1"
...@@ -3678,6 +3807,12 @@ version = "1.0.5"...@@ -3678,6 +3807,12 @@ version = "1.0.5"
3678source = "registry+https://github.com/rust-lang/crates.io-index"3807source = "registry+https://github.com/rust-lang/crates.io-index"
3679checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"3808checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
36803809
3810[[package]]
3811name = "utf8-zero"
3812version = "0.8.1"
3813source = "registry+https://github.com/rust-lang/crates.io-index"
3814checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
3815
3681[[package]]3816[[package]]
3682name = "utf8_iter"3817name = "utf8_iter"
3683version = "1.0.4"3818version = "1.0.4"
...@@ -3942,6 +4077,15 @@ dependencies = [...@@ -3942,6 +4077,15 @@ dependencies = [
3942 "wasm-bindgen",4077 "wasm-bindgen",
3943]4078]
39444079
4080[[package]]
4081name = "webpki-roots"
4082version = "1.0.9"
4083source = "registry+https://github.com/rust-lang/crates.io-index"
4084checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
4085dependencies = [
4086 "rustls-pki-types",
4087]
4088
3945[[package]]4089[[package]]
3946name = "weezl"4090name = "weezl"
3947version = "0.1.12"4091version = "0.1.12"
LICENSE created+15
...@@ -0,0 +1,15 @@
1ISC License
2
3Copyright (c) 2026 clover caruso
4
5Permission to use, copy, modify, and/or distribute this software for any
6purpose with or without fee is hereby granted, provided that the above
7copyright notice and this permission notice appear in all copies.
8
9THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
apps/ios/Info.plist+12
...@@ -8,6 +8,18 @@...@@ -8,6 +8,18 @@
8 <true/>8 <true/>
9 <key>NSCameraUsageDescription</key>9 <key>NSCameraUsageDescription</key>
10 <string>Take a photo to put on a page.</string>10 <string>Take a photo to put on a page.</string>
11 <key>NSUbiquitousContainers</key>
12 <dict>
13 <key>iCloud.net.paperclover.snowbound</key>
14 <dict>
15 <key>NSUbiquitousContainerIsDocumentScopePublic</key>
16 <true/>
17 <key>NSUbiquitousContainerName</key>
18 <string>Snowbound</string>
19 <key>NSUbiquitousContainerSupportedFolderLevels</key>
20 <string>Any</string>
21 </dict>
22 </dict>
11 <key>NSLocalNetworkUsageDescription</key>23 <key>NSLocalNetworkUsageDescription</key>
12 <string>Snowbound opens notebooks on file servers on your network.</string>24 <string>Snowbound opens notebooks on file servers on your network.</string>
13 <key>UIApplicationSceneManifest</key>25 <key>UIApplicationSceneManifest</key>
apps/ios/Snowbound.entitlements created+18
...@@ -0,0 +1,18 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.developer.icloud-container-identifiers</key>
6 <array>
7 <string>iCloud.net.paperclover.snowbound</string>
8 </array>
9 <key>com.apple.developer.icloud-services</key>
10 <array>
11 <string>CloudDocuments</string>
12 </array>
13 <key>com.apple.developer.ubiquity-container-identifiers</key>
14 <array>
15 <string>iCloud.net.paperclover.snowbound</string>
16 </array>
17</dict>
18</plist>
apps/ios/Snowbound.xcodeproj/project.pbxproj+4-2
...@@ -198,6 +198,7 @@...@@ -198,6 +198,7 @@
198 isa = XCBuildConfiguration;198 isa = XCBuildConfiguration;
199 buildSettings = {199 buildSettings = {
200 ARCHS = arm64;200 ARCHS = arm64;
201 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
201 CURRENT_PROJECT_VERSION = 1;202 CURRENT_PROJECT_VERSION = 1;
202 GENERATE_INFOPLIST_FILE = YES;203 GENERATE_INFOPLIST_FILE = YES;
203 HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";204 HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
...@@ -219,7 +220,7 @@...@@ -219,7 +220,7 @@
219 "-framework",220 "-framework",
220 CoreText,221 CoreText,
221 );222 );
222 PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;223 PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound;
223 PRODUCT_NAME = "$(TARGET_NAME)";224 PRODUCT_NAME = "$(TARGET_NAME)";
224 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";225 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
225 SWIFT_EMIT_LOC_STRINGS = NO;226 SWIFT_EMIT_LOC_STRINGS = NO;
...@@ -233,6 +234,7 @@...@@ -233,6 +234,7 @@
233 isa = XCBuildConfiguration;234 isa = XCBuildConfiguration;
234 buildSettings = {235 buildSettings = {
235 ARCHS = arm64;236 ARCHS = arm64;
237 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
236 CURRENT_PROJECT_VERSION = 1;238 CURRENT_PROJECT_VERSION = 1;
237 GENERATE_INFOPLIST_FILE = YES;239 GENERATE_INFOPLIST_FILE = YES;
238 HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";240 HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
...@@ -254,7 +256,7 @@...@@ -254,7 +256,7 @@
254 "-framework",256 "-framework",
255 CoreText,257 CoreText,
256 );258 );
257 PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;259 PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound;
258 PRODUCT_NAME = "$(TARGET_NAME)";260 PRODUCT_NAME = "$(TARGET_NAME)";
259 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";261 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
260 SWIFT_EMIT_LOC_STRINGS = NO;262 SWIFT_EMIT_LOC_STRINGS = NO;
apps/ios/Snowbound/App.swift+25-1
...@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
20 ) -> Bool {20 ) -> Bool {
21 sb_set_coordinator(coordinate)21 sb_set_coordinator(coordinate)
22 ICloud.start()
22 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }23 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
23 #if DEBUG24 #if DEBUG
24 Presenter.watch()25 Presenter.watch()
...@@ -45,7 +46,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -45,7 +46,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
4546
46/// Where the reader was, reopened on the next launch as Notes reopens its last note.47/// Where the reader was, reopened on the next launch as Notes reopens its last note.
47private struct Place: Codable {48private struct Place: Codable {
48 let notebook: UUID49 let notebook: String
49 let section: String50 let section: String
50 let page: String?51 let page: String?
5152
...@@ -90,6 +91,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -90,6 +91,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
90 self?.restore(notebook)91 self?.restore(notebook)
91 }92 }
92 }93 }
94 // iCloud Drive's notebooks list once its folder is found, and again when the account
95 // changes.
96 NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in
97 self?.notebooks.rescan { self?.restore($0) }
98 }
93 }99 }
94100
95 /// Reopens the section and page shown last, or the scripted one.101 /// Reopens the section and page shown last, or the scripted one.
...@@ -171,6 +177,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -171,6 +177,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
171 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)177 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
172 }178 }
173179
180 /// Returns to the list from any section of `notebook` shown, as before its folder moves.
181 func close(_ notebook: Notebook) {
182 guard pages.section?.notebook === notebook else { return }
183 pages.clear()
184 showingPage = false
185 split.setViewController(UIViewController(), for: .secondary)
186 split.show(.primary)
187 }
188
174 /// Adds a page to the open section and opens it, its title ready for typing.189 /// Adds a page to the open section and opens it, its title ready for typing.
175 func newPage(subpage: Bool = false) {190 func newPage(subpage: Bool = false) {
176 guard let section = pages.section else { return }191 guard let section = pages.section else { return }
...@@ -194,6 +209,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -194,6 +209,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
194 /// Composition ends and every edit is stored and published while the system allows.209 /// Composition ends and every edit is stored and published while the system allows.
195 func sceneDidEnterBackground(_ scene: UIScene) {210 func sceneDidEnterBackground(_ scene: UIScene) {
196 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)211 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
212 for notebook in Notebooks.all { notebook.pause() }
197 let sections = Section.all213 let sections = Section.all
198 guard !sections.isEmpty, background == .invalid else { return }214 guard !sections.isEmpty, background == .invalid else { return }
199 background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in215 background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in
...@@ -211,8 +227,16 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -211,8 +227,16 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
211 background = .invalid227 background = .invalid
212 }228 }
213229
230 /// Someone moving to another device finds their edits there: they publish now rather than
231 /// after a pause in typing.
232 func sceneWillResignActive(_ scene: UIScene) {
233 for section in Section.all { section.wake() }
234 }
235
214 /// Changes made elsewhere while away show at once.236 /// Changes made elsewhere while away show at once.
215 func sceneWillEnterForeground(_ scene: UIScene) {237 func sceneWillEnterForeground(_ scene: UIScene) {
238 for notebook in Notebooks.all { notebook.resume() }
239 notebooks.rescan()
216 for section in Section.all { section.wake() }240 for section in Section.all { section.wake() }
217 }241 }
218}242}
apps/ios/Snowbound/CanvasView.swift+40-7
...@@ -628,20 +628,25 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -628,20 +628,25 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
628 }628 }
629 }629 }
630630
631 /// The Pencil, or a drawing finger, comes down at `point` in the scroll view's bounds.631 /// The Pencil, or a drawing finger, comes down at `sample`'s point in the scroll view's bounds.
632 func inkPressed(_ point: CGPoint) {632 func inkPressed(_ sample: InkSample) {
633 guard let handle else { return }633 guard let handle else { return }
634 // Drawing puts the keyboard away, as Notes does.634 // Drawing puts the keyboard away, as Notes does.
635 if isFirstResponder { _ = resignFirstResponder() }635 if isFirstResponder { _ = resignFirstResponder() }
636 use(inkTool)636 use(inkTool)
637 editMenu.dismissMenu()637 editMenu.dismissMenu()
638 let point = visible(point)638 let point = visible(sample.point)
639 sb_view_pressure(handle, sample.pressure)
639 edit { sb_view_press(handle, Float(point.x), Float(point.y)) }640 edit { sb_view_press(handle, Float(point.x), Float(point.y)) }
640 }641 }
641642
642 func inkMoved(_ points: ArraySlice<CGPoint>) {643 func inkMoved(_ samples: ArraySlice<InkSample>) {
643 guard let handle else { return }644 guard let handle else { return }
644 for point in points.map(visible) { _ = sb_view_drag(handle, Float(point.x), Float(point.y)) }645 for sample in samples {
646 let point = visible(sample.point)
647 sb_view_pressure(handle, sample.pressure)
648 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
649 }
645 dirty = true650 dirty = true
646 }651 }
647652
...@@ -664,7 +669,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -664,7 +669,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
664 _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,669 _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
665 suggestedActions: [UIMenuElement]670 suggestedActions: [UIMenuElement]
666 ) -> UIMenu? {671 ) -> UIMenu? {
667 guard configuration.identifier as? NSString == Self.inkMenu else { return UIMenu(children: suggestedActions) }672 guard configuration.identifier as? NSString == Self.inkMenu else {
673 return selectedTextRange.flatMap { editMenu(for: $0, suggestedActions: suggestedActions) }
674 ?? UIMenu(children: suggestedActions)
675 }
668 return UIMenu(children: [676 return UIMenu(children: [
669 UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in677 UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
670 self?.deleteInk()678 self?.deleteInk()
...@@ -828,7 +836,32 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -828,7 +836,32 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
828 self?.apply(10)836 self?.apply(10)
829 }, at: 0)837 }, at: 0)
830 }838 }
831 return UIMenu(children: suggestedActions + [UIMenu(options: .displayInline, children: extra)])839 return UIMenu(
840 children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)])
841 }
842
843 private struct Correction: Decodable {
844 let word: String
845 let suggestions: [String]
846 let repeated: Bool
847 }
848
849 /// OneNote's corrections for the marked word at the selection, first in the menu.
850 private func spellingActions() -> [UIMenuElement] {
851 guard let handle, let correction = decode(Correction.self, sb_view_correction(handle)) else { return [] }
852 let correct = { [weak self] (command: UInt8, text: String?) in
853 guard let self, let handle = self.handle else { return }
854 self.edit(external: true) { sb_view_correct(handle, command, text) }
855 }
856 let replacements =
857 correction.repeated
858 ? [UIAction(title: "Delete Repeated Word") { _ in correct(0, "") }]
859 : correction.suggestions.map { word in UIAction(title: word) { _ in correct(0, word) } }
860 let ignore = UIAction(title: "Ignore") { _ in correct(1, nil) }
861 let learn = UIAction(title: "Add to Dictionary") { _ in correct(2, nil) }
862 return [
863 UIMenu(options: .displayInline, children: replacements + (correction.repeated ? [ignore] : [ignore, learn]))
864 ]
832 }865 }
833866
834 // MARK: Page867 // MARK: Page
apps/ios/Snowbound/ICloud.swift created+80
...@@ -0,0 +1,80 @@
1import Foundation
2
3/// Lists the conflict versions iCloud Drive keeps beside a section file, as it keeps another
4/// device's commit that lost; the library merges each into the file.
5private let listVersions: sb_versions = { path, found, context in
6 guard let path, let found else { return }
7 let url = URL(fileURLWithPath: String(cString: path))
8 for version in NSFileVersion.unresolvedConflictVersionsOfItem(at: url) ?? [] {
9 version.url.path.withCString { id in
10 guard let device = version.localizedNameOfSavingComputer else { return found(context, id, nil) }
11 device.withCString { found(context, id, $0) }
12 }
13 }
14}
15
16/// Resolves and removes a version the library merged, first keeping it beside the file as
17/// “Name (Device).one” when it is another section. A version already gone, as another device
18/// resolved it, is done.
19private let retireVersion: sb_retire = { path, id, keep in
20 guard let path, let id else { return false }
21 let url = URL(fileURLWithPath: String(cString: path))
22 let named = String(cString: id)
23 guard let version = NSFileVersion.unresolvedConflictVersionsOfItem(at: url)?.first(where: { $0.url.path == named })
24 else { return true }
25 var retired = false
26 var error: NSError?
27 NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { url in
28 if keep {
29 let device = version.localizedNameOfSavingComputer ?? "another device"
30 let stem = url.deletingPathExtension().lastPathComponent
31 let folder = url.deletingLastPathComponent()
32 let kept = (1...).lazy.map { n in
33 folder.appendingPathComponent(n == 1 ? "\(stem) (\(device)).one" : "\(stem) (\(device) \(n)).one")
34 }.first { !FileManager.default.fileExists(atPath: $0.path) }
35 guard let kept, (try? FileManager.default.copyItem(at: version.url, to: kept)) != nil else { return }
36 }
37 version.isResolved = true
38 retired = (try? version.remove()) != nil
39 }
40 return retired
41}
42
43/// Snowbound's folder in iCloud Drive: its container's Documents, which Files shows as iCloud
44/// Drive's Snowbound folder.
45enum ICloud {
46 /// Sent on the main thread when the folder was looked up again.
47 static let changed = Notification.Name("ICloudChanged")
48 private static let identifier = "iCloud.net.paperclover.snowbound"
49
50 /// The folder, once looked up; nil while iCloud Drive is off, signed out, or the app lacks
51 /// the container.
52 private(set) static var documents: URL?
53
54 /// Whether iCloud is signed in with iCloud Drive on, container or not.
55 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }
56
57 /// Has the library merge conflict versions, and follows the account; call once at launch.
58 static func start() {
59 sb_set_versions(listVersions, retireVersion)
60 NotificationCenter.default.addObserver(
61 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
62 ) { _ in lookUp() }
63 lookUp()
64 }
65
66 /// Looks the folder up off the main thread, as asking iCloud can take a while.
67 static func lookUp() {
68 background({ () -> URL? in
69 guard
70 let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?
71 .appendingPathComponent("Documents", isDirectory: true)
72 else { return nil }
73 try? FileManager.default.createDirectory(at: container, withIntermediateDirectories: true)
74 return container
75 }) { found in
76 documents = found
77 NotificationCenter.default.post(name: changed, object: nil)
78 }
79 }
80}
apps/ios/Snowbound/Ink.swift+14-5
...@@ -43,19 +43,28 @@ struct Pen {...@@ -43,19 +43,28 @@ struct Pen {
43 }43 }
44}44}
4545
46/// A point a touch passed, in the view's bounds, and the Pencil's pressure there from 0 to its
47/// greatest, or -1 for a finger, whose strokes keep their pen's width.
48typealias InkSample = (point: CGPoint, pressure: Float)
49
46/// Follows one touch from the moment it lands, gathering every point the hardware sampled,50/// Follows one touch from the moment it lands, gathering every point the hardware sampled,
47/// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two51/// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two
48/// fingers scroll; a hand resting beside the Pencil is ignored.52/// fingers scroll; a hand resting beside the Pencil is ignored.
49final class InkGesture: UIGestureRecognizer {53final class InkGesture: UIGestureRecognizer {
50 /// Points the touch passed since the action last took them, in the view's bounds.54 /// Points the touch passed since the action last took them.
51 var points: [CGPoint] = []55 var points: [InkSample] = []
52 private var tracked: UITouch?56 private var tracked: UITouch?
5357
58 private func sample(_ touch: UITouch) -> InkSample {
59 let pressure = touch.type == .pencil ? Float(touch.force / touch.maximumPossibleForce) : -1
60 return (touch.preciseLocation(in: view), pressure)
61 }
62
54 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {63 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
55 for touch in touches {64 for touch in touches {
56 if tracked == nil {65 if tracked == nil {
57 tracked = touch66 tracked = touch
58 points = [touch.preciseLocation(in: view)]67 points = [sample(touch)]
59 state = .began68 state = .began
60 } else if tracked?.type == .direct {69 } else if tracked?.type == .direct {
61 state = .cancelled70 state = .cancelled
...@@ -67,13 +76,13 @@ final class InkGesture: UIGestureRecognizer {...@@ -67,13 +76,13 @@ final class InkGesture: UIGestureRecognizer {
6776
68 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {77 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
69 guard let tracked, touches.contains(tracked) else { return }78 guard let tracked, touches.contains(tracked) else { return }
70 points += (event.coalescedTouches(for: tracked) ?? [tracked]).map { $0.preciseLocation(in: view) }79 points += (event.coalescedTouches(for: tracked) ?? [tracked]).map(sample)
71 state = .changed80 state = .changed
72 }81 }
7382
74 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {83 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
75 guard let tracked, touches.contains(tracked) else { return }84 guard let tracked, touches.contains(tracked) else { return }
76 points.append(tracked.preciseLocation(in: view))85 points.append(sample(tracked))
77 state = .ended86 state = .ended
78 }87 }
7988
apps/ios/Snowbound/Lists.swift+551-154
...@@ -1,21 +1,40 @@...@@ -1,21 +1,40 @@
1import UIKit1import UIKit
2import UniformTypeIdentifiers2import UniformTypeIdentifiers
33
4/// Every open notebook with its sections, as Notes lists each account's folders.4/// The places notebooks are kept, each listing its notebooks with their sections, as Files
5final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate {5/// lists its locations and Notes each account's folders.
6 private enum Item {6final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate {
7 case group(String, depth: Int)7 private enum Location: Hashable {
8 case section(Tab, depth: Int)8 /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on.
9 case opening9 case icloud
10 case problem(String)10 /// Snowbound's Documents, which Files shows as Snowbound's folder.
11 }11 case onDevice
1212 /// Notebooks opened from Files or a server.
13 private var items: [[Item]] = []13 case elsewhere
14 private var selected: (notebook: UUID, path: String)?14 }
15
16 private enum Item: Hashable {
17 case location(Location)
18 case notebook(String)
19 /// A section group, by its `/`-separated path.
20 case group(notebook: String, path: String)
21 case section(notebook: String, path: String)
22 /// Opening, or why the notebook cannot be.
23 case status(notebook: String)
24 case newNotebook, openFolder, connect
25 /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off.
26 case newInCloud, turnOnICloud
27 }
28
29 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
30 private var collapsed: Set<Item> = []
31 private var selected: Item?
15 var onOpen: ((Tab, Notebook) -> Void)?32 var onOpen: ((Tab, Notebook) -> Void)?
1633
34 private static let device = "On My \(UIDevice.current.model)"
35
17 init() {36 init() {
18 super.init(style: .insetGrouped)37 super.init(collectionViewLayout: UICollectionViewFlowLayout())
19 title = "Notebooks"38 title = "Notebooks"
20 navigationItem.largeTitleDisplayMode = .always39 navigationItem.largeTitleDisplayMode = .always
21 }40 }
...@@ -24,23 +43,79 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele...@@ -24,23 +43,79 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
2443
25 override func viewDidLoad() {44 override func viewDidLoad() {
26 super.viewDidLoad()45 super.viewDidLoad()
27 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row")46 collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { [weak self] _, environment in
47 let compact = environment.traitCollection.horizontalSizeClass == .compact
48 var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)
49 list.headerMode = .firstItemInSection
50 list.trailingSwipeActionsConfigurationProvider = { indexPath in
51 guard let self, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
52 if case .notebook(let id) = item, let notebook = self.notebook(id) {
53 return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))
54 }
55 guard item == .location(.onDevice) else { return nil }
56 let hide = UIContextualAction(style: .normal, title: "Hide") { [weak self] _, _, done in
57 self?.showOnDevice(false)
58 done(true)
59 }
60 hide.image = UIImage(systemName: "eye.slash")
61 return UISwipeActionsConfiguration(actions: [hide])
62 }
63 return NSCollectionLayoutSection.list(using: list, layoutEnvironment: environment)
64 }
65 let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] cell, _, item in
66 self?.configure(cell, item)
67 }
68 dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in
69 view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item)
70 }
71 dataSource.sectionSnapshotHandlers.willCollapseItem = { [weak self] in self?.collapsed.insert($0) }
72 dataSource.sectionSnapshotHandlers.willExpandItem = { [weak self] in self?.collapsed.remove($0) }
73
28 let add = UIMenu(children: [74 let add = UIMenu(children: [
29 UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in75 UIDeferredMenuElement.uncached { [weak self] provide in
30 self?.openFromFiles()76 guard let self else { return provide([]) }
31 },77 var actions: [UIMenuElement] = []
32 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in78 if ICloud.documents != nil || Notebooks.showsOnDevice {
33 self?.connect()79 actions.append(
34 },80 UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in
35 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in81 self?.newNotebook(inCloud: ICloud.documents != nil)
36 guard let self else { return }82 })
37 Author.ask(from: self) {}83 }
38 },84 actions += [
85 UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in
86 self?.openFolder()
87 },
88 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
89 self?.connect()
90 },
91 ]
92 var more: [UIMenuElement] = []
93 if !Notebooks.showsOnDevice {
94 more.append(
95 UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in
96 self?.showOnDevice(true)
97 })
98 }
99 more.append(
100 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
101 guard let self else { return }
102 Author.ask(from: self) {}
103 })
104 provide(actions + [UIMenu(options: .displayInline, children: more)])
105 }
39 ])106 ])
40 navigationItem.rightBarButtonItem = UIBarButtonItem(107 navigationItem.rightBarButtonItem = UIBarButtonItem(
41 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)108 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
42 navigationItem.searchController = SearchViewController.controller()109 navigationItem.searchController = SearchViewController.controller()
43 toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]110 toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]
111 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
112 guard let self else { return }
113 reload()
114 var shown = dataSource.snapshot()
115 shown.reconfigureItems(shown.itemIdentifiers)
116 dataSource.apply(shown, animatingDifferences: false)
117 }
118 reload()
44 }119 }
45120
46 override func viewWillAppear(_ animated: Bool) {121 override func viewWillAppear(_ animated: Bool) {
...@@ -49,47 +124,403 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele...@@ -49,47 +124,403 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
49 navigationController?.setToolbarHidden(false, animated: false)124 navigationController?.setToolbarHidden(false, animated: false)
50 }125 }
51126
127 private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
128
52 func reload() {129 func reload() {
53 items = Notebooks.all.map { notebook in130 guard isViewLoaded else { return }
54 if notebook.handle == nil {131 let cloud: [(Location, [Notebook], [Item])] =
55 return [notebook.problem.map(Item.problem) ?? .opening]132 ICloud.documents != nil
56 }133 ? [(.icloud, Notebooks.inCloud, [.newInCloud])]
57 var rows: [Item] = []134 : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])]
58 var group = ""135 let locations: [(Location, [Notebook], [Item])] =
59 for tab in notebook.tabs {136 cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : [])
60 if tab.group != group {137 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])]
61 // Each group level not yet shown gets its own row.138 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
62 let parts = tab.group.split(separator: "/").map(String.init)139 sections.appendSections(locations.map(\.0))
63 let shown = group.split(separator: "/").map(String.init)140 dataSource.apply(sections, animatingDifferences: false)
64 let common = zip(parts, shown).prefix { $0 == $1 }.count141 for (location, notebooks, actions) in locations {
65 for depth in common..<parts.count { rows.append(.group(parts[depth], depth: depth)) }142 var list = NSDiffableDataSourceSectionSnapshot<Item>()
66 group = tab.group143 let header = Item.location(location)
144 list.append([header])
145 list.append(notebooks.map { .notebook($0.id) } + actions, to: header)
146 for notebook in notebooks {
147 let parent = Item.notebook(notebook.id)
148 guard notebook.handle != nil else {
149 list.append([.status(notebook: notebook.id)], to: parent)
150 continue
151 }
152 for tab in notebook.tabs {
153 var parent = parent
154 var path = ""
155 for part in tab.group.split(separator: "/") {
156 path += (path.isEmpty ? "" : "/") + part
157 let group = Item.group(notebook: notebook.id, path: path)
158 if !list.contains(group) { list.append([group], to: parent) }
159 parent = group
160 }
161 list.append([.section(notebook: notebook.id, path: tab.path)], to: parent)
67 }162 }
68 rows.append(.section(tab, depth: tab.group.split(separator: "/").count))
69 }163 }
70 return rows164 list.expand(list.items.filter { !collapsed.contains($0) })
165 dataSource.apply(list, to: location, animatingDifferences: false)
71 }166 }
72 tableView.reloadData()
73 showSelection()167 showSelection()
74 var empty = UIContentUnavailableConfiguration.empty()168 }
75 empty.text = "No Notebooks"169
76 empty.secondaryText = "Open a OneNote notebook from Files or a server."170 /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs
77 contentUnavailableConfiguration = items.isEmpty ? empty : nil171 /// as each opens.
172 func rescan(opened: ((Notebook) -> Void)? = nil) {
173 for notebook in Notebooks.scanICloud() + Notebooks.scan() {
174 notebook.open { [weak self] in
175 self?.reload()
176 opened?(notebook)
177 }
178 }
179 reload()
180 }
181
182 private func showOnDevice(_ shown: Bool) {
183 Notebooks.showsOnDevice = shown
184 rescan()
78 }185 }
79186
80 func select(_ tab: Tab, of notebook: Notebook) {187 func select(_ tab: Tab, of notebook: Notebook) {
81 selected = (notebook.id, tab.path)188 selected = .section(notebook: notebook.id, path: tab.path)
82 showSelection()189 showSelection()
83 }190 }
84191
85 /// Marks the open section, as the sidebar of a wide window keeps it.192 /// Marks the open section, as the sidebar of a wide window keeps it.
86 private func showSelection() {193 private func showSelection() {
87 guard let selected, let section = Notebooks.all.firstIndex(where: { $0.id == selected.notebook }),194 guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return }
88 let row = items[section].firstIndex(where: {195 collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
89 if case .section(let tab, _) = $0 { tab.path == selected.path } else { false }196 }
90 })197
198 private func configure(_ cell: UICollectionViewListCell, _ item: Item) {
199 var content = cell.defaultContentConfiguration()
200 var accessories: [UICellAccessory] = []
201 switch item {
202 case .location(let location):
203 content.text =
204 switch location {
205 case .icloud: "iCloud Drive"
206 case .onDevice: Self.device
207 case .elsewhere: "Other Locations"
208 }
209 accessories = [.outlineDisclosure(options: .init(style: .header))]
210 case .notebook(let id):
211 guard let notebook = notebook(id) else { break }
212 content.text = notebook.name
213 content.image = UIImage(systemName: "book.closed")
214 if case .server(let server) = notebook.source { content.secondaryText = server.host }
215 let more = UIButton(type: .system)
216 more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
217 more.accessibilityLabel = "More"
218 more.showsMenuAsPrimaryAction = true
219 more.menu = menu(for: notebook)
220 accessories = [
221 .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),
222 .outlineDisclosure(options: .init(style: .cell)),
223 ]
224 case .group(_, let path):
225 content.text = path.split(separator: "/").last.map(String.init)
226 content.image = UIImage(systemName: "folder")
227 content.textProperties.color = .secondaryLabel
228 accessories = [.outlineDisclosure(options: .init(style: .cell))]
229 case .section(let id, let path):
230 guard let tab = notebook(id)?.tabs.first(where: { $0.path == path }) else { break }
231 content.text = tab.name
232 content.image = UIImage(
233 systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")
234 content.imageProperties.tintColor = tab.uiColor
235 if !tab.readable {
236 content.secondaryText = tab.downloading ? "Downloading…" : tab.copy.map { "Copy of “\($0)”" } ?? tab.problem ?? "Can’t be opened here"
237 content.textProperties.color = .secondaryLabel
238 }
239 case .status(let id):
240 if let problem = notebook(id)?.problem {
241 content.text = "Can’t Open Notebook"
242 content.secondaryText = problem
243 content.image = UIImage(systemName: "exclamationmark.triangle")
244 content.imageProperties.tintColor = .systemOrange
245 let retry = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
246 accessories = [.customView(configuration: .init(customView: retry, placement: .trailing()))]
247 } else {
248 content.text = "Opening…"
249 content.textProperties.color = .secondaryLabel
250 let spinner = UIActivityIndicatorView(style: .medium)
251 spinner.startAnimating()
252 accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))]
253 }
254 case .turnOnICloud:
255 content.text = "Turn On iCloud Drive"
256 content.secondaryText = "Keep notebooks in iCloud to use them on all your devices."
257 content.image = UIImage(systemName: "icloud")
258 content.textProperties.color = .tintColor
259 case .newInCloud:
260 content.text = "New Notebook…"
261 content.image = UIImage(systemName: "plus")
262 content.textProperties.color = .tintColor
263 case .newNotebook, .openFolder, .connect:
264 content.text =
265 item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…"
266 content.image = UIImage(
267 systemName: item == .newNotebook ? "plus" : item == .openFolder ? "folder" : "server.rack")
268 content.textProperties.color = .tintColor
269 }
270 cell.contentConfiguration = content
271 cell.accessories = accessories
272 }
273
274 private func menu(for notebook: Notebook) -> UIMenu {
275 var actions = [
276 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
277 [weak self] _ in self?.newSection(in: notebook)
278 },
279 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
280 guard let self else { return }
281 (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
282 },
283 ]
284 // A notebook On My iPhone is its folder there, handled as Files handles a folder.
285 guard let folder = folder(of: notebook) else {
286 let image = UIImage(systemName: "xmark.circle")
287 let close = UIAction(title: "Close Notebook", image: image, attributes: .destructive) { [weak self] _ in
288 Notebooks.remove(notebook)
289 self?.reload()
290 }
291 return UIMenu(children: actions + [close])
292 }
293 let file = [
294 UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in
295 self?.rename(folder, of: notebook)
296 },
297 UIAction(title: "Duplicate", image: UIImage(systemName: "plus.square.on.square")) { [weak self] _ in
298 self?.duplicate(folder, of: notebook)
299 },
300 UIAction(title: "Share…", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in
301 self?.share(folder, from: notebook)
302 },
303 UIAction(title: "Show in Files", image: UIImage(systemName: "folder")) { _ in
304 // Files opens the folder a `shareddocuments` URL names.
305 if let url = URL(string: "shareddocuments://" + folder.path) { UIApplication.shared.open(url) }
306 },
307 ]
308 let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
309 [weak self] _ in self?.delete(folder, of: notebook)
310 }
311 return UIMenu(children: [
312 UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete,
313 ])
314 }
315
316 private func swipeActions(for notebook: Notebook, at indexPath: IndexPath) -> [UIContextualAction] {
317 guard let folder = folder(of: notebook) else {
318 let close = UIContextualAction(style: .destructive, title: "Close") { [weak self] _, _, done in
319 Notebooks.remove(notebook)
320 self?.reload()
321 done(true)
322 }
323 close.image = UIImage(systemName: "xmark.circle")
324 return [close]
325 }
326 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
327 self?.delete(folder, of: notebook)
328 done(true)
329 }
330 delete.image = UIImage(systemName: "trash")
331 let share = UIContextualAction(style: .normal, title: "Share") { [weak self] _, _, done in
332 self?.share(folder, from: notebook)
333 done(true)
334 }
335 share.image = UIImage(systemName: "square.and.arrow.up")
336 share.backgroundColor = .systemBlue
337 return [delete, share]
338 }
339
340 /// The notebook's folder or section file On My iPhone; nil for one opened elsewhere.
341 private func folder(of notebook: Notebook) -> URL? {
342 guard case .documents(let path) = notebook.source else { return nil }
343 return documentsDirectory.appendingPathComponent(path)
344 }
345
346 /// Publishes the notebook's edits and lets go of it, closing any section of it shown,
347 /// then runs `act`; refuses with why while an edit can't be published, as moving the
348 /// folder then would strand it.
349 private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {
350 for scene in UIApplication.shared.connectedScenes {
351 (scene.delegate as? SceneDelegate)?.close(notebook)
352 }
353 guard let handle = notebook.handle else { return act() }
354 let pointer = Int(bitPattern: handle)
355 background({ () -> String? in
356 var error: UnsafeMutablePointer<CChar>?
357 let closed = sb_library_close(OpaquePointer(bitPattern: pointer), 10, &error)
358 let problem = take(error)
359 return closed ? nil : problem ?? ""
360 }) { [weak self] problem in
361 if let problem {
362 self?.refuse(title, problem)
363 return
364 }
365 notebook.pause()
366 act()
367 }
368 }
369
370 private func rename(_ folder: URL, of notebook: Notebook) {
371 let alert = UIAlertController(title: "Rename", message: nil, preferredStyle: .alert)
372 let isFile = folder.pathExtension.lowercased() == "one"
373 let rename = UIAlertAction(title: "Rename", style: .default) { [weak self, weak alert] _ in
374 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
375 let target = folder.deletingLastPathComponent().appendingPathComponent(
376 isFile ? name + "." + folder.pathExtension : name)
377 guard target != folder else { return }
378 guard !FileManager.default.fileExists(atPath: target.path) else {
379 self?.refuse("“\(name)” Already Exists", "Choose a different name.")
380 return
381 }
382 self?.close(notebook, refusing: "Can’t Rename") {
383 do {
384 try FileManager.default.moveItem(at: folder, to: target)
385 _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil)
386 } catch {
387 self?.refuse("Can’t Rename", error.localizedDescription)
388 }
389 self?.rescan()
390 }
391 }
392 alert.addTextField { field in
393 field.text = folder.deletingPathExtension().lastPathComponent
394 field.autocapitalizationType = .words
395 field.clearButtonMode = .whileEditing
396 NotificationCenter.default.addObserver(
397 forName: UITextField.textDidChangeNotification, object: field, queue: .main
398 ) { _ in
399 let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
400 rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
401 }
402 }
403 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
404 alert.addAction(rename)
405 alert.preferredAction = rename
406 present(alert, animated: true)
407 }
408
409 /// Copies the notebook beside itself, named as Files names a duplicate.
410 private func duplicate(_ folder: URL, of notebook: Notebook) {
411 // The copy takes what the open sections have stored.
412 for section in Section.all where section.notebook === notebook && !section.flush(10) {
413 return refuse("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.")
414 }
415 let base = folder.deletingPathExtension().lastPathComponent
416 let ext = folder.pathExtension
417 var copy = folder
418 for n in 2... {
419 let name = base + " \(n)"
420 copy = folder.deletingLastPathComponent().appendingPathComponent(ext.isEmpty ? name : name + "." + ext)
421 if !FileManager.default.fileExists(atPath: copy.path) { break }
422 }
423 let target = copy
424 background({ () -> String? in
425 do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription }
426 return nil
427 }) { [weak self] problem in
428 if let problem { self?.refuse("Can’t Duplicate", problem) }
429 self?.rescan()
430 }
431 }
432
433 private func share(_ folder: URL, from notebook: Notebook) {
434 let sheet = UIActivityViewController(activityItems: [folder], applicationActivities: nil)
435 if let popover = sheet.popoverPresentationController {
436 if let indexPath = dataSource.indexPath(for: .notebook(notebook.id)),
437 let cell = collectionView.cellForItem(at: indexPath)
438 {
439 popover.sourceView = cell
440 popover.sourceRect = cell.bounds
441 } else {
442 popover.sourceView = view
443 }
444 }
445 present(sheet, animated: true)
446 }
447
448 /// Moves the notebook to Recently Deleted, or, where the system keeps none, removes it
449 /// once confirmed.
450 private func delete(_ folder: URL, of notebook: Notebook) {
451 close(notebook, refusing: "Can’t Delete") { [weak self] in self?.remove(folder, of: notebook) }
452 }
453
454 private func remove(_ folder: URL, of notebook: Notebook) {
455 if (try? FileManager.default.trashItem(at: folder, resultingItemURL: nil)) != nil {
456 return rescan()
457 }
458 let name = folder.deletingPathExtension().lastPathComponent
459 let alert = UIAlertController(
460 title: "Delete “\(name)”?", message: "This notebook will be deleted immediately. You can’t undo this action.",
461 preferredStyle: .alert)
462 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
463 notebook.open { self?.reload() }
464 })
465 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
466 do { try FileManager.default.removeItem(at: folder) } catch {
467 self?.refuse("Can’t Delete", error.localizedDescription)
468 }
469 self?.rescan()
470 })
471 present(alert, animated: true)
472 }
473
474 override func collectionView(
475 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
476 ) -> UIContextMenuConfiguration? {
477 guard indexPaths.count == 1, case .notebook(let id) = dataSource.itemIdentifier(for: indexPaths[0]),
478 let notebook = notebook(id)
479 else { return nil }
480 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.menu(for: notebook) })
481 }
482
483 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
484 guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
485 switch item {
486 case .section(let id, let path):
487 return notebook(id)?.tabs.first(where: { $0.path == path })?.readable == true
488 case .status(let id):
489 guard let notebook = notebook(id), notebook.problem != nil else { return false }
490 let spinner = UIActivityIndicatorView(style: .medium)
491 spinner.startAnimating()
492 (collectionView.cellForItem(at: indexPath) as? UICollectionViewListCell)?.accessories = [
493 .customView(configuration: .init(customView: spinner, placement: .trailing()))
494 ]
495 notebook.open { [weak self] in self?.reload() }
496 case .notebook, .group:
497 // The row opens and closes, as a folder of Files' sidebar does.
498 guard let location = dataSource.sectionIdentifier(for: indexPath.section) else { return false }
499 var list = dataSource.snapshot(for: location)
500 if list.isExpanded(item) {
501 list.collapse([item])
502 collapsed.insert(item)
503 } else {
504 list.expand([item])
505 collapsed.remove(item)
506 }
507 dataSource.apply(list, to: location)
508 case .newNotebook: newNotebook(inCloud: false)
509 case .newInCloud: newNotebook(inCloud: true)
510 case .turnOnICloud:
511 if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) }
512 case .openFolder: openFolder()
513 case .connect: connect()
514 case .location: break
515 }
516 return false
517 }
518
519 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
520 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
521 let tab = notebook.tabs.first(where: { $0.path == path })
91 else { return }522 else { return }
92 tableView.selectRow(at: IndexPath(row: row, section: section), animated: false, scrollPosition: .none)523 onOpen?(tab, notebook)
93 }524 }
94525
95 private func add(_ notebook: Notebook) {526 private func add(_ notebook: Notebook) {
...@@ -102,7 +533,66 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele...@@ -102,7 +533,66 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
102 }533 }
103 }534 }
104535
105 private func openFromFiles() {536 /// Creates a notebook in iCloud Drive or On My iPhone, as OneNote's New Notebook does, and
537 /// opens it.
538 private func newNotebook(inCloud: Bool) {
539 guard let author = Author.name else {
540 return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }
541 }
542 let alert = UIAlertController(title: "New Notebook", message: nil, preferredStyle: .alert)
543 let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
544 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
545 self?.create(name, by: author, inCloud: inCloud)
546 }
547 create.isEnabled = false
548 alert.addTextField { field in
549 field.placeholder = "Name"
550 field.autocapitalizationType = .words
551 NotificationCenter.default.addObserver(
552 forName: UITextField.textDidChangeNotification, object: field, queue: .main
553 ) { _ in
554 let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
555 create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
556 }
557 }
558 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
559 alert.addAction(create)
560 alert.preferredAction = create
561 present(alert, animated: true)
562 }
563
564 private func create(_ name: String, by author: String, inCloud: Bool) {
565 let place = inCloud ? ICloud.documents : documentsDirectory
566 guard let folder = place?.appendingPathComponent(name) else { return }
567 guard !FileManager.default.fileExists(atPath: folder.path) else {
568 return refuse("“\(name)” Already Exists", "Choose a different name.")
569 }
570 let (date, time) = titleDate()
571 background({ () -> String? in
572 var error: UnsafeMutablePointer<CChar>?
573 let made = sb_notebook_create(folder.path, cacheDirectory.path, author, date, time, &error)
574 let problem = take(error)
575 return made ? nil : problem ?? ""
576 }) { [weak self] problem in
577 guard let self else { return }
578 if let problem { return refuse("Can’t Create Notebook", problem) }
579 if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
580 rescan { [weak self] notebook in
581 guard notebook.source == (inCloud ? .icloud(path: name) : .documents(path: name)),
582 let tab = notebook.tabs.first(where: \.readable)
583 else { return }
584 self?.onOpen?(tab, notebook)
585 }
586 }
587 }
588
589 private func refuse(_ title: String, _ message: String) {
590 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
591 alert.addAction(UIAlertAction(title: "OK", style: .default))
592 present(alert, animated: true)
593 }
594
595 private func openFolder() {
106 let picker = UIDocumentPickerViewController(596 let picker = UIDocumentPickerViewController(
107 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])597 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
108 picker.delegate = self598 picker.delegate = self
...@@ -145,109 +635,6 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele...@@ -145,109 +635,6 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
145 })635 })
146 present(alert, animated: true)636 present(alert, animated: true)
147 }637 }
148
149 override func numberOfSections(in tableView: UITableView) -> Int { items.count }
150
151 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
152 items[section].count
153 }
154
155 override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
156 let notebook = Notebooks.all[section]
157 var title = UIButton.Configuration.plain()
158 title.title = notebook.name
159 title.image = UIImage(systemName: "ellipsis.circle")
160 title.imagePlacement = .trailing
161 title.imagePadding = 6
162 title.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4)
163 title.baseForegroundColor = .label
164 title.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
165 var attributes = $0
166 attributes.font = UIFont.preferredFont(forTextStyle: .title3).withTraits(.traitBold)
167 return attributes
168 }
169 let button = UIButton(configuration: title)
170 button.contentHorizontalAlignment = .leading
171 button.showsMenuAsPrimaryAction = true
172 button.menu = UIMenu(children: [
173 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
174 [weak self] _ in self?.newSection(in: notebook)
175 },
176 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
177 guard let self else { return }
178 (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
179 },
180 UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) {
181 [weak self] _ in
182 Notebooks.remove(notebook)
183 self?.reload()
184 },
185 ])
186 return button
187 }
188
189 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
190 let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath)
191 var content = cell.defaultContentConfiguration()
192 cell.accessoryType = .none
193 cell.selectionStyle = .none
194 cell.accessoryView = nil
195 switch items[indexPath.section][indexPath.row] {
196 case .group(let name, let depth):
197 content.text = name
198 content.image = UIImage(systemName: "folder")
199 content.textProperties.color = .secondaryLabel
200 content.directionalLayoutMargins.leading += CGFloat(depth) * 20
201 case .section(let tab, let depth):
202 content.text = tab.name
203 content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill")
204 content.imageProperties.tintColor = tab.uiColor
205 content.directionalLayoutMargins.leading += CGFloat(depth) * 20
206 if tab.readable {
207 cell.accessoryType = .disclosureIndicator
208 cell.selectionStyle = .default
209 } else {
210 content.secondaryText = "Can’t be opened here"
211 content.textProperties.color = .secondaryLabel
212 }
213
214 case .opening:
215 content.text = "Opening…"
216 content.textProperties.color = .secondaryLabel
217 let spinner = UIActivityIndicatorView(style: .medium)
218 spinner.startAnimating()
219 cell.accessoryView = spinner
220 case .problem(let problem):
221 content.text = "Can’t Open Notebook"
222 content.secondaryText = problem
223 content.image = UIImage(systemName: "exclamationmark.triangle")
224 content.imageProperties.tintColor = .systemOrange
225 cell.accessoryView = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
226 cell.selectionStyle = .default
227 }
228 cell.contentConfiguration = content
229 return cell
230 }
231
232 override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
233 switch items[indexPath.section][indexPath.row] {
234 case .section(let tab, _): tab.readable ? indexPath : nil
235 case .problem: indexPath
236 default: nil
237 }
238 }
239
240 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
241 let notebook = Notebooks.all[indexPath.section]
242 switch items[indexPath.section][indexPath.row] {
243 case .section(let tab, _): onOpen?(tab, notebook)
244 case .problem:
245 items[indexPath.section] = [.opening]
246 tableView.reloadSections([indexPath.section], with: .none)
247 notebook.open { [weak self] in self?.reload() }
248 default: break
249 }
250 }
251}638}
252639
253extension UIFont {640extension UIFont {
...@@ -308,6 +695,16 @@ final class PagesViewController: UITableViewController {...@@ -308,6 +695,16 @@ final class PagesViewController: UITableViewController {
308 navigationController?.setToolbarHidden(true, animated: false)695 navigationController?.setToolbarHidden(true, animated: false)
309 }696 }
310697
698 /// Lets go of the section shown, as when its notebook goes.
699 func clear() {
700 section = nil
701 items = []
702 title = nil
703 tableView.reloadData()
704 contentUnavailableConfiguration = nil
705 navigationController?.setToolbarHidden(true, animated: false)
706 }
707
311 func failed(_ tab: Tab, _ problem: String?) {708 func failed(_ tab: Tab, _ problem: String?) {
312 var empty = UIContentUnavailableConfiguration.empty()709 var empty = UIContentUnavailableConfiguration.empty()
313 empty.text = "Can’t Open Section"710 empty.text = "Can’t Open Section"
apps/ios/Snowbound/Notebooks.swift+176-30
...@@ -109,6 +109,8 @@ enum Source: Codable, Equatable {...@@ -109,6 +109,8 @@ enum Source: Codable, Equatable {
109 case files(bookmark: Data)109 case files(bookmark: Data)
110 /// A folder in the app's Documents, which Files shows as Snowbound's.110 /// A folder in the app's Documents, which Files shows as Snowbound's.
111 case documents(path: String)111 case documents(path: String)
112 /// A folder in Snowbound's folder in iCloud Drive (`ICloud.documents`).
113 case icloud(path: String)
112 /// A notebook folder on an SMB share, opened through Snowbound's own client.114 /// A notebook folder on an SMB share, opened through Snowbound's own client.
113 case server(Server)115 case server(Server)
114 /// A folder or section at an absolute path, for scripted runs.116 /// A folder or section at an absolute path, for scripted runs.
...@@ -124,6 +126,12 @@ struct Tab: Decodable, Equatable {...@@ -124,6 +126,12 @@ struct Tab: Decodable, Equatable {
124 let group: String126 let group: String
125 let color: [UInt8]127 let color: [UInt8]
126 let readable: Bool128 let readable: Bool
129 /// Not on this device yet; `Notebook` asks iCloud Drive for it.
130 let downloading: Bool
131 /// The section this file copies, which opens instead.
132 let copy: String?
133 /// Why the file could not be read, where retrying or repair may help.
134 let problem: String?
127135
128 var uiColor: UIColor {136 var uiColor: UIColor {
129 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)137 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
...@@ -150,7 +158,8 @@ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: ....@@ -150,7 +158,8 @@ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .
150158
151/// An open notebook: its Rust library and the sections it lists.159/// An open notebook: its Rust library and the sections it lists.
152final class Notebook {160final class Notebook {
153 let id: UUID161 /// A UUID, or `Documents/` and the folder's name for a notebook On My iPhone.
162 let id: String
154 let source: Source163 let source: Source
155 private(set) var name: String164 private(set) var name: String
156 private(set) var handle: OpaquePointer?165 private(set) var handle: OpaquePointer?
...@@ -161,8 +170,12 @@ final class Notebook {...@@ -161,8 +170,12 @@ final class Notebook {
161 private var accessed: URL?170 private var accessed: URL?
162 /// Reports other apps' changes to a folder on this device to the library.171 /// Reports other apps' changes to a folder on this device to the library.
163 private var presenter: FolderPresenter?172 private var presenter: FolderPresenter?
173 /// Lists the sections again while iCloud Drive downloads some.
174 private var recheck: DispatchWorkItem?
175 /// Posted on the main thread when the sections list again on their own.
176 static let listed = Notification.Name("NotebookListed")
164177
165 init(id: UUID = UUID(), name: String, source: Source) {178 init(id: String = UUID().uuidString, name: String, source: Source) {
166 self.id = id179 self.id = id
167 self.name = name180 self.name = name
168 self.source = source181 self.source = source
...@@ -205,6 +218,16 @@ final class Notebook {...@@ -205,6 +218,16 @@ final class Notebook {
205 let url = documentsDirectory.appendingPathComponent(path)218 let url = documentsDirectory.appendingPathComponent(path)
206 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)219 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
207 return (library, nil, take(error), url)220 return (library, nil, take(error), url)
221 case .icloud(let path):
222 guard let url = ICloud.documents?.appendingPathComponent(path) else {
223 return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil)
224 }
225 var library: OpaquePointer?
226 var coordination: NSError?
227 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
228 library = sb_library_open(url.path, cacheDirectory.path, false, &error)
229 }
230 return (library, nil, take(error) ?? coordination?.localizedDescription, url)
208 case .path(let path):231 case .path(let path):
209 let url = URL(fileURLWithPath: path)232 let url = URL(fileURLWithPath: path)
210 let local = onThisDevice(url)233 let local = onThisDevice(url)
...@@ -233,7 +256,8 @@ final class Notebook {...@@ -233,7 +256,8 @@ final class Notebook {
233 var location: String {256 var location: String {
234 switch source {257 switch source {
235 case .files: "In Files"258 case .files: "In Files"
236 case .documents: "On this \(UIDevice.current.model), in Snowbound’s folder"259 case .documents: "On My \(UIDevice.current.model), in Snowbound’s folder"
260 case .icloud: "In iCloud Drive"
237 case .server(let server):261 case .server(let server):
238 "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))"262 "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))"
239 case .path(let path): path263 case .path(let path): path
...@@ -249,9 +273,51 @@ final class Notebook {...@@ -249,9 +273,51 @@ final class Notebook {
249 let pointer = Int(bitPattern: handle)273 let pointer = Int(bitPattern: handle)
250 background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in274 background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in
251 if let tabs { self.tabs = tabs }275 if let tabs { self.tabs = tabs }
276 download()
252 done()277 done()
253 }278 }
254 }279 }
280
281 /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as
282 /// `.Name.icloud`, and lists the sections again until they arrive.
283 private func download() {
284 let folder: URL? =
285 switch source {
286 case .files: accessed
287 case .documents(let path): documentsDirectory.appendingPathComponent(path)
288 case .icloud(let path): ICloud.documents?.appendingPathComponent(path)
289 case .path(let path): URL(fileURLWithPath: path)
290 case .server: nil
291 }
292 guard let folder, recheck == nil,
293 let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil)
294 else { return }
295 var waiting = false
296 for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" {
297 let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst())
298 let file = stub.deletingLastPathComponent().appendingPathComponent(name)
299 try? FileManager.default.startDownloadingUbiquitousItem(at: file)
300 waiting = true
301 }
302 guard waiting else { return }
303 let recheck = DispatchWorkItem { [weak self] in
304 self?.recheck = nil
305 self?.reload { NotificationCenter.default.post(name: Self.listed, object: self) }
306 }
307 self.recheck = recheck
308 DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: recheck)
309 }
310
311 /// Stops hearing other apps' writes while in the background, where a suspended presenter
312 /// can deadlock their coordinated writes.
313 func pause() { presenter?.stop() }
314
315 /// Hears other apps' writes again, and checks the sections and their list for those
316 /// missed meanwhile.
317 func resume() {
318 presenter?.resume()
319 reload { NotificationCenter.default.post(name: Self.listed, object: self) }
320 }
255}321}
256322
257/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere323/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
...@@ -281,7 +347,12 @@ final class FolderPresenter: NSObject, NSFilePresenter {...@@ -281,7 +347,12 @@ final class FolderPresenter: NSObject, NSFilePresenter {
281 NSFileCoordinator.addFilePresenter(self)347 NSFileCoordinator.addFilePresenter(self)
282 }348 }
283349
284 /// Stops reporting, before the library goes.350 func resume() {
351 NSFileCoordinator.addFilePresenter(self)
352 touched("")
353 }
354
355 /// Stops reporting, before the library goes or the app leaves the foreground.
285 func stop() {356 func stop() {
286 NSFileCoordinator.removeFilePresenter(self)357 NSFileCoordinator.removeFilePresenter(self)
287 presentedItemOperationQueue.waitUntilAllOperationsAreFinished()358 presentedItemOperationQueue.waitUntilAllOperationsAreFinished()
...@@ -290,6 +361,8 @@ final class FolderPresenter: NSObject, NSFilePresenter {...@@ -290,6 +361,8 @@ final class FolderPresenter: NSObject, NSFilePresenter {
290 func presentedItemDidChange() { touched("") }361 func presentedItemDidChange() { touched("") }
291 func presentedSubitemDidChange(at url: URL) { touched(url) }362 func presentedSubitemDidChange(at url: URL) { touched(url) }
292 func presentedSubitemDidAppear(at url: URL) { touched(url) }363 func presentedSubitemDidAppear(at url: URL) { touched(url) }
364 /// Another device's commit that lost in iCloud Drive, which changes no file.
365 func presentedSubitem(at url: URL, didGain version: NSFileVersion) { touched(url) }
293 func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {366 func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
294 touched(oldURL)367 touched(oldURL)
295 touched(newURL)368 touched(newURL)
...@@ -309,59 +382,132 @@ final class FolderPresenter: NSObject, NSFilePresenter {...@@ -309,59 +382,132 @@ final class FolderPresenter: NSObject, NSFilePresenter {
309 }382 }
310}383}
311384
312/// The notebooks the list shows, kept across launches.385/// The notebooks the list shows: those in Documents, and those opened from elsewhere, kept
386/// across launches.
313enum Notebooks {387enum Notebooks {
314 private struct Entry: Codable {388 private struct Entry: Codable {
315 let id: UUID389 let id: String
316 let name: String390 let name: String
317 let source: Source391 let source: Source
318 }392 }
319393
320 private static let key = "notebooks"394 private static let key = "notebooks"
321 private(set) static var all: [Notebook] = []395 private static let hidden = "hidesOnDevice"
322396 private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]
323 /// The kept notebooks, or on first launch a copy of the bundled sample in Documents.397 /// The folders and sections in Documents, as On My iPhone lists them.
398 private(set) static var onDevice: [Notebook] = []
399 /// The folders in Snowbound's folder in iCloud Drive.
400 private(set) static var inCloud: [Notebook] = []
401 private(set) static var elsewhere: [Notebook] = []
402 static var all: [Notebook] { inCloud + onDevice + elsewhere }
403
404 /// Whether the list shows On My iPhone, as Files lets a location be hidden.
405 static var showsOnDevice: Bool {
406 get { scripted == nil && !UserDefaults.standard.bool(forKey: hidden) }
407 set { UserDefaults.standard.set(!newValue, forKey: hidden) }
408 }
409
410 /// The kept notebooks and those in Documents, where the bundled sample goes on first launch.
324 static func load() {411 static func load() {
325 if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] {412 if let scripted {
326 all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))]413 elsewhere = [
414 Notebook(
415 name: URL(fileURLWithPath: scripted).deletingPathExtension().lastPathComponent,
416 source: .path(scripted))
417 ]
327 return418 return
328 }419 }
329 if let data = UserDefaults.standard.data(forKey: key),420 if let data = UserDefaults.standard.data(forKey: key),
330 let entries = try? JSONDecoder().decode([Entry].self, from: data)421 let entries = try? JSONDecoder().decode([Entry].self, from: data)
331 {422 {
332 all = entries.map { Notebook(id: $0.id, name: $0.name, source: $0.source) }423 elsewhere = entries.compactMap { entry in
333 return424 switch entry.source {
425 case .documents, .icloud: return nil
426 default: break
427 }
428 return Notebook(id: entry.id, name: entry.name, source: entry.source)
429 }
430 } else {
431 let sample = documentsDirectory.appendingPathComponent("Sample")
432 if !FileManager.default.fileExists(atPath: sample.path),
433 let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil)
434 {
435 try? FileManager.default.copyItem(at: bundled, to: sample)
436 }
437 // The notebook the first version remembered from Files.
438 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
439 var stale = false
440 let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?
441 .deletingPathExtension().lastPathComponent
442 elsewhere.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))
443 }
444 save()
334 }445 }
335 let sample = documentsDirectory.appendingPathComponent("Sample")446 scan()
336 if !FileManager.default.fileExists(atPath: sample.path),447 }
337 let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil)448
338 {449 /// Lists Documents again, keeping the notebooks still there; returns those new to the list.
339 try? FileManager.default.copyItem(at: bundled, to: sample)450 @discardableResult
451 static func scan() -> [Notebook] {
452 guard showsOnDevice else {
453 onDevice = []
454 return []
340 }455 }
341 all = [Notebook(name: "Sample", source: .documents(path: "Sample"))]456 var added: [Notebook] = []
342 // The notebook the first version remembered from Files.457 onDevice = notebooks(in: documentsDirectory).map { name in
343 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {458 if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept }
344 var stale = false459 let notebook = Notebook(
345 let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?460 id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name))
346 .deletingPathExtension().lastPathComponent461 added.append(notebook)
347 all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))462 return notebook
348 }463 }
349 save()464 return added
465 }
466
467 /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while
468 /// iCloud Drive is off.
469 @discardableResult
470 static func scanICloud() -> [Notebook] {
471 guard let folder = ICloud.documents, scripted == nil else {
472 inCloud = []
473 return []
474 }
475 var added: [Notebook] = []
476 inCloud = notebooks(in: folder).map { name in
477 if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept }
478 let notebook = Notebook(
479 id: "iCloud/" + name, name: (name as NSString).deletingPathExtension, source: .icloud(path: name))
480 added.append(notebook)
481 return notebook
482 }
483 return added
484 }
485
486 /// The notebook folders and section files in `folder`, by name, as Files orders them.
487 private static func notebooks(in folder: URL) -> [String] {
488 let listed = try? FileManager.default.contentsOfDirectory(
489 at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
490 return (listed ?? []).filter { url in
491 (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
492 || url.pathExtension.lowercased() == "one"
493 }
494 .map(\.lastPathComponent)
495 .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
350 }496 }
351497
352 static func add(_ notebook: Notebook) {498 static func add(_ notebook: Notebook) {
353 all.append(notebook)499 elsewhere.append(notebook)
354 save()500 save()
355 }501 }
356502
357 static func remove(_ notebook: Notebook) {503 static func remove(_ notebook: Notebook) {
358 all.removeAll { $0 === notebook }504 elsewhere.removeAll { $0 === notebook }
359 save()505 save()
360 }506 }
361507
362 private static func save() {508 private static func save() {
363 guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return }509 guard scripted == nil else { return }
364 let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) }510 let entries = elsewhere.map { Entry(id: $0.id, name: $0.name, source: $0.source) }
365 UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key)511 UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key)
366 }512 }
367}513}
apps/ios/Snowbound/Script.swift+9-1
...@@ -38,9 +38,11 @@ extension CanvasView {...@@ -38,9 +38,11 @@ extension CanvasView {
38 let values = argument.split(separator: ",").compactMap { Double($0) }38 let values = argument.split(separator: ",").compactMap { Double($0) }
39 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))39 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
40 case "pencil":40 case "pencil":
41 // Points as `x,y`, or `x,y,pressure` for the Pencil's pressure from 0 to 1.
41 let points = argument.split(separator: ";").map {42 let points = argument.split(separator: ";").map {
42 let values = $0.split(separator: ",").compactMap { Double($0) }43 let values = $0.split(separator: ",").compactMap { Double($0) }
43 return CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)44 let point = CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
45 return (point, values.count > 2 ? Float(values[2]) : -1) as InkSample
44 }46 }
45 inkPressed(points[0])47 inkPressed(points[0])
46 inkMoved(points.dropFirst())48 inkMoved(points.dropFirst())
...@@ -80,6 +82,12 @@ extension CanvasView {...@@ -80,6 +82,12 @@ extension CanvasView {
80 case "rotate":82 case "rotate":
81 let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight83 let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight
82 window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))84 window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
85 case "menu":
86 guard let range = selectedTextRange,
87 let menu = interactions.lazy.compactMap({ $0 as? UIEditMenuInteraction }).first(where: { $0.delegate === self })
88 else { break }
89 let caret = caretRect(for: range.start)
90 menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
83 case "done": _ = resignFirstResponder()91 case "done": _ = resignFirstResponder()
84 case "selectall": selectAll(nil)92 case "selectall": selectAll(nil)
85 case "format": apply(UInt8(argument) ?? 0)93 case "format": apply(UInt8(argument) ?? 0)
apps/ios/Snowbound/Sync.swift+6-2
...@@ -3,7 +3,7 @@ import UIKit...@@ -3,7 +3,7 @@ import UIKit
3/// A section's sync status from `sb_library_sync_status`.3/// A section's sync status from `sb_library_sync_status`.
4struct SectionSync: Decodable {4struct SectionSync: Decodable {
5 enum State: UInt8, Decodable, Comparable {5 enum State: UInt8, Decodable, Comparable {
6 case upToDate, syncing, inUse, notConnected, readOnly, failed6 case upToDate, syncing, inUse, notConnected, readOnly, protected, unreadable, failed
77
8 static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue }8 static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue }
9 }9 }
...@@ -55,6 +55,8 @@ enum Sync {...@@ -55,6 +55,8 @@ enum Sync {
55 case .syncing: "Syncing…"55 case .syncing: "Syncing…"
56 case .inUse: "Section in use"56 case .inUse: "Section in use"
57 case .notConnected: "Not connected"57 case .notConnected: "Not connected"
58 case .protected: "Password protected"
59 case .unreadable: "Can’t read this section"
58 case .readOnly, .failed: "Unable to sync"60 case .readOnly, .failed: "Unable to sync"
59 }61 }
60 }62 }
...@@ -65,7 +67,8 @@ enum Sync {...@@ -65,7 +67,8 @@ enum Sync {
65 case .upToDate: "checkmark.circle"67 case .upToDate: "checkmark.circle"
66 case .syncing, .inUse: "arrow.triangle.2.circlepath"68 case .syncing, .inUse: "arrow.triangle.2.circlepath"
67 case .notConnected: "wifi.slash"69 case .notConnected: "wifi.slash"
68 case .readOnly, .failed: "exclamationmark.triangle"70 case .protected: "lock"
71 case .readOnly, .unreadable, .failed: "exclamationmark.triangle"
69 }72 }
70 }73 }
7174
...@@ -77,6 +80,7 @@ enum Sync {...@@ -77,6 +80,7 @@ enum Sync {
77 case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)."80 case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)."
78 case .inUse: "Someone else is saving this section. Sync continues when they finish."81 case .inUse: "Someone else is saving this section. Sync continues when they finish."
79 case .notConnected: "Changes stay on this \(device) and sync when the notebook is back."82 case .notConnected: "Changes stay on this \(device) and sync when the notebook is back."
83 case .protected: "Snowbound can’t open password-protected sections yet."
80 default: nil84 default: nil
81 }85 }
82 }86 }
arc/canvas.md+67-9
...@@ -106,15 +106,40 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's...@@ -106,15 +106,40 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's
106grid point, outside any outline. It selects, drags on the grid, deletes and106grid point, outside any outline. It selects, drags on the grid, deletes and
107moves with Insert Space as a picture does, and opens and saves as above.107moves with Insert Space as a picture does, and opens and saves as above.
108108
109An outline holding only pictures or files takes a paragraph after them where a
110click beside them lands, as OneNote 2010 adds one when typing there. It is
111stored with the first edit that reaches it, and undoing back to it empty takes it
112out again (`corpus/object-outline`).
113
114A file printout's pages are pictures whose stored data is the printout's XPS
115package. The page draws the PNG OneNote rendered of each
116(WebPictureContainer14), framed in grey as OneNote frames them, and moves them as
117pictures. Snowbound does not write XPS, so a printout page deleted and brought
118back by undo returns as that picture (`corpus/printout`).
119
109## Recordings120## Recordings
110121
111Record Audio works as OneNote 2010's does. The caret's paragraph splits around122Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph
112a line saying when recording started, in OneNote's grey `cite` style, and the123splits around a line saying when recording started, in OneNote's grey `cite`
113caret goes on below it. Text written while recording links to the moment it124style, and the caret goes on below it. Text written while recording links to
114was written. On Stop, the file goes in above that line, named after the page.125the moment it was written; text written while paused links to nothing, and the
115Hovering a linked note or a recording shows OneNote's blue play button in the126pause is left out of later moments. On Stop, the file goes in above that line,
116margin, and a click asks the host to play from that moment. The host records a127named after the page. Hovering a linked note or a recording shows OneNote's blue
11716 kHz WAV and stores it as IMA ADPCM, which OneNote plays.128play button in the margin, and a click asks the host to play from five seconds
129before that moment, OneNote's default rewind. While a recording plays, See
130Playback highlights the note linked last at or before the moment playing,
131scrolling it into view when it changes.
132
133The host records audio as 16 kHz WAV and stores it as IMA ADPCM, and video as
134AVI of Motion JPEG at 320 by 240 and 15 pictures a second with PCM sound, both
135of which OneNote plays (`corpus/recording/video`). GStreamer writes that AVI
136directly; on macOS a capture session records a movie that `AVAssetReader` reads
137back into it off the main thread after Stop, the transport saying so and the page
138editable meanwhile. Mac OS X 10.6, without AVFoundation, opens recordings in the
139system's player and does not record. Snowbound plays both itself: the sound through the platform's
140player, decoded from IMA ADPCM first, and a video's pictures in the transport
141over the page, which also carries OneNote's Pause, Stop, ten-second and
142ten-minute skips, Seek To and See Playback.
118143
119## Drawing144## Drawing
120145
...@@ -133,6 +158,23 @@ click on ink picks it. The default pen draws in the open section's accent at...@@ -133,6 +158,23 @@ click on ink picks it. The default pen draws in the open section's accent at
133the light theme's shade, so a stroke stores one real colour and shows it in both158the light theme's shade, so a stroke stores one real colour and shows it in both
134themes.159themes.
135160
161A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as
162OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times
1630.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y
164(`corpus/ink-pressure`). A mouse or finger draws at the pen's width.
165
166## Tables and selections across them
167
168Note tags on a table sit in the tag column centred on it, and a click checks its
169box (`corpus/table-tags`). A selection crossing a table's edge deletes as
170OneNote 2010 deletes one: nothing joins across the edge, cells inside are
171emptied, and where the selection runs on past the table its rows inside go, the
172table with them when all do. What replaces the selection goes in at its start
173(`corpus/cross-container`). Tab and Link leave such a selection alone, and
174Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs
175makes each paragraph's part an equation, and a deletion between two equations
176joins them where neither seam lies inside an object (`corpus/equation-join`).
177
136## Content the editor doesn't understand178## Content the editor doesn't understand
137179
138Nothing is lost for being unfamiliar. A paragraph the canvas can't draw180Nothing is lost for being unfamiliar. A paragraph the canvas can't draw
...@@ -158,8 +200,24 @@ editable text area, whose runs carry the canvas's real line boxes and...@@ -158,8 +200,24 @@ editable text area, whose runs carry the canvas's real line boxes and
158character positions, so a screen reader's caret and selection land where the200character positions, so a screen reader's caret and selection land where the
159eye does. Native selection and replacement actions go through the editor's201eye does. Native selection and replacement actions go through the editor's
160history like any other edit. The tree updates only while an assistive client202history like any other edit. The tree updates only while an assistive client
161is listening, and never for a caret blink. The interface kit around the page203is listening, and never for a caret blink. The interface's own tree holds it at
162doesn't have a tree yet.204the page's box (see [the interface kit](ui.md#accessibility-and-the-keyboard)).
205
206## Spelling
207
208Spelling is checked as OneNote 2010 checks it with its default proofing
209options. Words in capitals, words with digits, and Internet and file addresses
210go unchecked, and a word repeating the one before it is marked as repeated.
211Each run is checked in the language it stores, so a French paragraph takes the
212French dictionary and an equation none. The host supplies the dictionary;
213`canvas::spelling` checks each paragraph on a thread of its own when it first
214comes into view, and keeps the result by the paragraph's text, so drawing never
215waits on it. Marked words carry OneNote's red zigzag, one pixel thick at any
216zoom and laid on the device's pixel grid so a 1× screen shows OneNote's exact
217pixels, except the word still being typed. The context menu (the edit menu on
218iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the
219Spelling pane (F7) walks the page's marked words. A correction is an ordinary
220edit, one revision. Nothing about spelling is stored in the page.
163221
164## Search, dates, conflicts222## Search, dates, conflicts
165223
arc/file-format.md+5-2
...@@ -169,9 +169,12 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:...@@ -169,9 +169,12 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:
169 note to a moment in a recording. Ink and math were reverse-engineered, each169 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 own170 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 objects171 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. Recordings172 OneNote stored while drawing them in the lab (`corpus/ink-tools`), and for pressure,
173 its PDF export of strokes it took in from ISF (`corpus/ink-pressure`). For math, the MathML it exports, matched byte for byte. Recordings
173 and their links follow what OneNote stored while recording in the lab, read174 and their links follow what OneNote stored while recording in the lab, read
174 back through its XML export (`corpus/recording`). Embedded objects are read175 back through its XML export (`corpus/recording`); OneNote lists attached
176 .avi, .mpg and .wmv files as video recordings but not .mp4 or .mov, so video
177 is stored as Motion JPEG AVI, which it plays. Embedded objects are read
175 and kept, but never authored.178 and kept, but never authored.
176179
177Readers stay tolerant (files in the wild are older, odder, or written by other180Readers stay tolerant (files in the wild are older, odder, or written by other
arc/platforms.md+22-8
...@@ -22,7 +22,8 @@ page inside it. `snowbound` picks a platform module at compile time...@@ -22,7 +22,8 @@ page inside it. `snowbound` picks a platform module at compile time
22(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else22(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else
23in the crate is shared: library and settings, the sidebar, menus, page and23in the crate is shared: library and settings, the sidebar, menus, page and
24section management, templates, and screenshot and replay support.24section management, templates, and screenshot and replay support.
25Accessibility for the page goes through AccessKit's winit adapter on both.25Accessibility goes through AccessKit's winit adapter on both: the interface's
26tree, with the page's grafted into it.
26`commands.rs` is the one table of commands: each one's title, its chords on27`commands.rs` is the one table of commands: each one's title, its chords on
27macOS and elsewhere, when it is enabled or checked, and what it does. The28macOS and elsewhere, when it is enabled or checked, and what it does. The
28keyboard, the toolbar and the macOS menu bar all run commands from it.29keyboard, the toolbar and the macOS menu bar all run commands from it.
...@@ -41,13 +42,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -41,13 +42,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
41 sidebar take the system's desktop tint, appearance and focus state exactly.42 sidebar take the system's desktop tint, appearance and focus state exactly.
42- AppKit supplies the open and save panels, alerts, the date picker, date43- AppKit supplies the open and save panels, alerts, the date picker, date
43 formatting for new page titles and conflict labels, the account's full name44 formatting for new page titles and conflict labels, the account's full name
44 (used as the author, as OneNote uses Office's user name), and the caret and45 (used as the author, as OneNote uses Office's user name), the caret and
45 selection colours.46 selection colours, and the spell checker, `NSSpellChecker`, which the
47 spelling thread calls on the main thread (10.6 has it too).
46- Frames present inside Core Animation's transaction, so a resize pairs each48- Frames present inside Core Animation's transaction, so a resize pairs each
47 frame with the window's new size instead of stretching the last one.49 frame with the window's new size instead of stretching the last one.
48- A notebook on a mounted SMB share is opened through the embedded SMB client,50- A notebook on a mounted SMB share is opened through the embedded SMB client,
49 signed in with the password the keychain keeps for that mount (see51 signed in with the password the keychain keeps for that mount (see
50 [sync](sync.md) for why the mount itself isn't enough).52 [sync](sync.md) for why the mount itself isn't enough). Open Notebook from Server… reaches one
53 by address instead, and keeps a password it asks to remember as the Finder does, an
54 SMB internet password.
51- The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are55- The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are
52 the command table's, validated from the statuses each frame publishes, and56 the command table's, validated from the statuses each frame publishes, and
53 their key equivalents are the table's chords, so AppKit takes a chord the57 their key equivalents are the table's chords, so AppKit takes a chord the
...@@ -75,11 +79,20 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -75,11 +79,20 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
75 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.79 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.
76- zenity or kdialog provide the pickers and alerts. The XDG settings portal80- zenity or kdialog provide the pickers and alerts. The XDG settings portal
77 provides the colour scheme. Text conventions come from the C library's81 provides the colour scheme. Text conventions come from the C library's
78 locale. Fontconfig is loaded at run time, so builds need no headers for it.82 locale. Fontconfig is loaded at run time, so builds need no headers for it,
83 and so is Enchant, which checks spelling with whatever dictionaries its
84 providers have; without it, words go unmarked.
79- Wayland's clipboard goes through the window's own connection, since not every85- Wayland's clipboard goes through the window's own connection, since not every
80 compositor offers a clipboard to clients without a window.86 compositor offers a clipboard to clients without a window.
81- `crates/snowbound/linux` packages a tarball, plus an installer that adds the87- The executable carries its desktop entry and icon (`desktop_linux.rs`). The
82 launcher entry the Wayland desktop needs to find the icon.88 window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a
89 Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the
90 icon only through an entry of that name, so a run that isn't installed
91 writes a hidden one pointing at an icon in the runtime folder, marked with
92 its process ID, and removes both on exit or at the next start after a crash.
93 KWin takes the icon from the window. Install on the welcome copies the
94 executable to `~/.local/bin` and writes the visible entry under the same
95 name, so the menu never lists two; Uninstall in Options removes it.
8396
84### Windows and older macOS97### Windows and older macOS
8598
...@@ -114,7 +127,8 @@ owns everything around the page:...@@ -114,7 +127,8 @@ owns everything around the page:
114No `ui` crate ships on iOS. Everything below the view is the desktop's code:127No `ui` crate ships on iOS. Everything below the view is the desktop's code:
115`notebook::session` with its replica and background publishing, the embedded128`notebook::session` with its replica and background publishing, the embedded
116SMB client (credentials kept in the Keychain, as Files keeps its own), conflict129SMB client (credentials kept in the Keychain, as Files keeps its own), conflict
117pages, search, and page management through the same ops. Notebooks from Files130pages, search, spelling (through `UITextChecker`), and page management through
131the same ops. Notebooks from Files
118are read and written under `NSFileCoordinator`, so file providers see every132are read and written under `NSFileCoordinator`, so file providers see every
119change.133change.
120134
arc/sync.md+54
...@@ -159,6 +159,13 @@ A notebook on a share that macOS has mounted is opened through the embedded...@@ -159,6 +159,13 @@ A notebook on a share that macOS has mounted is opened through the embedded
159client with the account the system keeps for that mount. It falls back to the159client with the account the system keeps for that mount. It falls back to the
160mount only when it can't sign in that way.160mount only when it can't sign in that way.
161161
162A notebook can also be opened from its server's address, with no mount at all (File ▸
163Open Notebook from Server…), which is how Mac OS X 10.6 reaches a server that no longer
164speaks SMB1, the only version its Finder has. The notebook is kept by that address
165(`smb://[domain;]user@server/share/folder`), never with a password; the password lives in
166the keychain (the Secret Service on Linux) when the user asks to remember it, and
167otherwise the sign-in asks again at the next launch.
168
162A file's identity is its root object space, not its server file ID, which169A file's identity is its root object space, not its server file ID, which
163changes when maintenance replaces the file. A check opens by path each time170changes when maintenance replaces the file. A check opens by path each time
164for the same reason.171for the same reason.
...@@ -228,6 +235,46 @@ copies and learns of other apps' writes through file coordination (`NSFilePresen...@@ -228,6 +235,46 @@ copies and learns of other apps' writes through file coordination (`NSFilePresen
228file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15235file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15
229seconds. Closing a notebook deletes its copies, except any with edits still waiting.236seconds. Closing a notebook deletes its copies, except any with edits still waiting.
230237
238## iCloud Drive
239
240A cloud drive syncs whole files with no lock and no compare-and-swap. The stamp check still runs
241before each append, but only against this device's copy; when two devices append to the same
242base, iCloud keeps one as the file and the other beside it as a **conflict version**
243(`NSFileVersion`), on every device. A conflict version is a stamp check that failed after the
244fact, and each sync step merges the versions it finds before anything else:
245
246```text
247for each version V the remote lists (Remote::versions)
248 C := the file, read coordinated
249 per space of V: a := its newest revision C holds, or holds merged
250 ancestor := V opened at those revisions (Section::open_at)
251 ops := lower_page(ancestor[S], V[S]) per page V changed, V's new pages imported under their
252 identities, its deletions, moves and conflict pages
253 replay ops on C through merge.rs (conflict pages where they clash), seal_as the named
254 revisions, publish on C's stamp; then retire V (resolved, removed)
255```
256
257Each revision the merge writes is named after the version's revision it merged (a hash of it),
258so "holds merged" is a lookup by revision identity. A version merged once, by this device or
259another, merges as nothing the next time; two devices merging one version at once write the
260same names, and the merge of their two results finds everything held. Any device merges any
261version at once; nothing waits for the device that wrote it. A version of another section, or
262one that cannot be read, is kept beside the file as `Name (Device).one` instead.
263
264On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the
265iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is
266asked for and read later; the replica serves it meanwhile. Each publication is an upload and a
267chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,
268`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,
269the Mac's window loses focus or the app quits. A folder presenter reports other devices'
270changes and conflict versions, which change no file, and every section is checked every 15
271seconds besides. Signing out of iCloud closes its notebooks; replicas holding edits stay.
272
273`crates/notebook/tests/cloud.rs` runs devices against a fake iCloud (uploads without
274compare-and-swap, either side of a race winning, late deliveries, offline spans) and checks that
275every device ends on the same file with each typed string once. OneNote 2010 cold-opens a
276merged section with its conflict page as its own (`corpus/icloud-merge`).
277
231## Notebook structure278## Notebook structure
232279
233Sections, groups and the notebook's own colour live in the `.onetoc2` files,280Sections, groups and the notebook's own colour live in the `.onetoc2` files,
...@@ -237,6 +284,13 @@ parent TOC's identity and a CRC of the file's name). Without them OneNote...@@ -237,6 +284,13 @@ parent TOC's identity and a CRC of the file's name). Without them OneNote
237treats the file as a stranger and re-identifies it. Deleting sends sections and284treats the file as a stranger and re-identifies it. Deleting sends sections and
238pages to `OneNote_RecycleBin`, as OneNote does.285pages to `OneNote_RecycleBin`, as OneNote does.
239286
287What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the
288Windows hidden attribute so that OneNote never makes a section group of it. Its files are
289plain files, not revision stores: pictures named by their content, and small JSON
290mappings that every writer merges before replacing. The first is tag art: a tag keeps
291OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and
292symbol to a picture that Snowbound draws in the symbol's place.
293
240## Recovery and migration294## Recovery and migration
241295
242`export_recovery` snapshots a replica into a single read-only archive: base and296`export_recovery` snapshots a replica into a single read-only archive: base and
arc/ui.md+51-4
...@@ -25,8 +25,9 @@ frame-rate independent, a retargeted animation continues smoothly from where...@@ -25,8 +25,9 @@ frame-rate independent, a retargeted animation continues smoothly from where
25it is, and it settles fast. Popups open and close on short timed curves,25it is, and it settles fast. Popups open and close on short timed curves,
26slow enough to follow: a menu swings out of the pointer or its button as it26slow enough to follow: a menu swings out of the pointer or its button as it
27fades in, a combo's field widens into its list, and a dialog swings up into27fades in, a combo's field widens into its list, and a dialog swings up into
28place over a dimmed window, as Windows opens a window; the command palette swings28place near the window's top over a dimmed window, as Windows opens a window; the
29in the same way near the window's top. On GNOME and KDE, menus instead29command palette swings in the same way, undimmed. A filtered list shows its new
30results at once. On GNOME and KDE, menus instead
30look and move as the desktop's own (see [platforms](platforms.md)).31look and move as the desktop's own (see [platforms](platforms.md)).
3132
32## Why not native widgets33## Why not native widgets
...@@ -85,7 +86,7 @@ frame N...@@ -85,7 +86,7 @@ frame N
85 are built each frame and the solver picks, so a group folds in the frame the86 are built each frame and the solver picks, so a group folds in the frame the
86 window narrows, with no widths remembered or worked out by the app. A folded87 window narrows, with no widths remembered or worked out by the app. A folded
87 group is a dropdown of the same command-table rows, and a gallery it lists88 group is a dropdown of the same command-table rows, and a gallery it lists
88 opens under it, since a folded-away box takes its group's rectangle. The89 opens beside its row as a submenu, on hover, Right or a click. The
89 section tabs scroll sideways past the row's room, fading out where cut.90 section tabs scroll sideways past the row's room, fading out where cut.
90- **Nothing runs while nothing changes.** The kit reports whether it wants91- **Nothing runs while nothing changes.** The kit reports whether it wants
91 another frame (queued input, an animation still settling) and when a timed92 another frame (queued input, an animation still settling) and when a timed
...@@ -106,6 +107,51 @@ has the OneNote-specific controls: section tabs and compact toolbar buttons....@@ -106,6 +107,51 @@ has the OneNote-specific controls: section tabs and compact toolbar buttons.
106The kit doesn't know what a notebook is. `snowbound` assembles the window from107The kit doesn't know what a notebook is. `snowbound` assembles the window from
107these parts.108these parts.
108109
110## Accessibility and the keyboard
111
112The interface presents itself to assistive technology as an AccessKit tree
113built from the same boxes as the frame. A box with a `Spec::role` is a node;
114the kit's widgets choose their own, and `Ui::access` adds states, values and
115names to any box. A box without a role lets its children through, and its
116text reads as a label.
117
118- **Names come from what a sighted user reads.** A control without one is
119 named by its text and its children's, or by its tooltip: the title names
120 it, the chord becomes its shortcut, and the description its description.
121 An icon button's command-table title is its name, and a split button's arrow
122 is that name with "Options", so no two toolbar controls share one.
123- **Popups are menus and dialogs.** A menu's highlighted row is the focus
124 within it, so arrows read as they move; a palette is a dialog whose field
125 keeps the focus while its list's selection moves. Opening a popup moves the
126 focus into it, and closing it gives the focus back. A command menu opens at
127 its top with nothing highlighted; a value picker (fonts, sizes, a combo's
128 list) opens on its current value.
129- **Actions are input.** A press or a new value from assistive technology
130 arrives as an event and is answered next frame, as a click would be.
131- **Only changes are sent**, and only while something listens.
132
133The page keeps its own tree (see [canvas](canvas.md#accessibility)). The
134host grafts it at the page's box, sending the interface's tree first, since
135it holds the graft, then the page's.
136
137```text
138Window
139├─ Toolbar buttons named by their tooltips, combos with their values
140├─ TabList section tabs
141├─ Group "Page" the page's own tree, grafted
142├─ TabList page tabs
143└─ Menu | Dialog open popups
144```
145
146The keyboard reaches the same controls. Tab steps through them, within an
147open popup or across the window; a toolbar, tab list or tree is one step,
148entered at its selected control, and arrows move within it. F6 steps between
149those groups and the page, as Windows and GTK step between panes, and on
150macOS Control-F5 goes to the toolbar. Space or Enter presses, Escape closes a
151popup or returns the focus to where the keyboard took it from, and the
152focused control wears a ring until the pointer is used. The page keeps Tab
153for itself, so F6 leaves it.
154
109## `draw`: the renderer155## `draw`: the renderer
110156
111`draw` is a small wgpu renderer. A frame is a list of layers, each with its157`draw` is a small wgpu renderer. A frame is a list of layers, each with its
...@@ -142,6 +188,7 @@ repaint, cache eviction and a new renderer must all produce identical pixels....@@ -142,6 +188,7 @@ repaint, cache eviction and a new renderer must all produce identical pixels.
142188
143A covered window gets no redraws, so interaction is scripted.189A covered window gets no redraws, so interaction is scripted.
144`SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot190`SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot
145steps. `--screenshot` draws the whole window offscreen in each appearance191steps, and `accessibility` steps that write the whole window's tree as text, so a
192hidden window's tree can be checked without a screen reader. `--screenshot` draws the whole window offscreen in each appearance
146(the README's screenshots are made this way). `ui`'s own tests build frames193(the README's screenshots are made this way). `ui`'s own tests build frames
147headlessly and assert on layout, routing and signals.194headlessly and assert on layout, routing and signals.
corpus/cross-container/README.md created+20
...@@ -0,0 +1,20 @@
1# Deleting across a table's edge
2
3`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: four pages in
4`Cross.one`, each "Before text", a two-by-two table and "After text".
5
6`keys/` is `tools/native_cross_container.py` pressing the same keys in OneNote 2010 on
7that notebook: a selection from "Before" into the first cell deleted, one from the
8second cell out to "After" deleted and "X" typed, one from "Before" over the first row
9into "Beta one" typed over with "Y", and one from "Before" over the whole table deleted.
10OneNote joins nothing across the table's edge, empties cells inside the selection, and
11where the selection runs on past the table removes its rows inside, and the table when
12all of them are.
13
14`candidate/` is the output of `deleting_across_a_tables_edge_keeps_each_side` in
15`crates/canvas/tests/cross_container.rs`, the same edits through the editor, each
16published as its own revision; `cold/` is a fresh OneNote 2010 read of it, whose pages
17read as `keys/` does. `tools/test_cross_container.py` checks this without a VM.
18Regenerate with `SNOWBOUND_CROSS_CONTAINER_EXPORT` set to a new absolute directory while
19running the test, then cold-open it with
20`tools/native_runner.py OUTPUT COLD --expected-pages 4 --collect-notebook --screenshots`.
corpus/cross-container/candidate/Cross.one created
Binary files /dev/null and b/corpus/cross-container/candidate/Cross.one differ
corpus/cross-container/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/cross-container/candidate/Open Notebook.onetoc2 differ
corpus/cross-container/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-CE2F338C -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 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/cross-container/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-ce2f338c", "hostname": "ONE-M6-CE2F338C"}
corpus/cross-container/cold/notebook/Cross.one created
Binary files /dev/null and b/corpus/cross-container/cold/notebook/Cross.one differ
corpus/cross-container/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/cross-container/cold/notebook/Open Notebook.onetoc2 differ
corpus/cross-container/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-CE2F338C",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/cross-container/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="{32E9BEC1-CB18-4230-BFF3-45B4B9C6E130}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T04:12:05.000Z" color="#B49EDE"><one:Section name="Cross" ID="{2CA95E30-1BE0-0DDE-3C87-5CE31E1DCA2A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cross.one" lastModifiedTime="2026-09-30T04:12:05.000Z" color="#91BAAE"><one:Page ID="{530571A0-F042-0F6F-28ED-96116E4E009E}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1"/><one:Page ID="{AA3723BB-B998-054F-37C3-A1A0A535DC25}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1"/><one:Page ID="{45EC3C00-41C0-0CB0-0963-4ABD33E76F83}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1"/><one:Page ID="{22D52BB5-E205-04E6-097C-F6544278B69F}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/cross-container/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": "Delete a whole table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/cold/read/page-000.png created
Binary files /dev/null and b/corpus/cross-container/cold/read/page-000.png differ
corpus/cross-container/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="{22D52BB5-E205-04E6-097C-F6544278B69F}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3D9963F6-AA39-0FEE-1960-3B74F7634329}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{3D9963F6-AA39-0FEE-1960-3B74F7634329}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{3D9963F6-AA39-0FEE-1960-3B74F7634329}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{3D9963F6-AA39-0FEE-1960-3B74F7634329}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/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": "Type over a row - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/cold/read/page-001.png created
Binary files /dev/null and b/corpus/cross-container/cold/read/page-001.png differ
corpus/cross-container/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="{45EC3C00-41C0-0CB0-0963-4ABD33E76F83}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="89.83252716064453" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[BeforeY]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="44.5925178527832"/></one:Columns><one:Row objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{47}{B0}" lastModifiedTime="2025-08-18T14:13:22.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{BEFDA9E2-6173-08CE-080A-B206385A6A00}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/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": "Delete into a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/cold/read/page-002.png created
Binary files /dev/null and b/corpus/cross-container/cold/read/page-002.png differ
corpus/cross-container/cold/read/page-002.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="{530571A0-F042-0F6F-28ED-96116E4E009E}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="102.3262329101562" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{46}{B0}"><one:Columns><one:Column index="0" width="44.29708862304687"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{ACD8269A-D389-0CCA-27FC-A2B8C6449A79}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/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": "Delete out of a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/cold/read/page-003.png created
Binary files /dev/null and b/corpus/cross-container/cold/read/page-003.png differ
corpus/cross-container/cold/read/page-003.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="{AA3723BB-B998-054F-37C3-A1A0A535DC25}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="94.84369659423828" height="48.80315780639648"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[AlphaX]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{4421DF5E-829B-07B9-127C-5BC27EB2CF5C}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/cross-container/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/cross-candidate",
3 "expected_pages": 4,
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/cross-container/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/cross-container/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/cross-container/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Cross.one",
4 "bytes": 36880,
5 "sha256": "c799433c13b9d28d6b302e70c50a5cfaad91ed1ad6324ee288ac4394a4f4b749",
6 "mtime_ns": 1790741247924484176
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 1604,
11 "sha256": "335f7cd71e022c2f7447d6369ff1cad37aaba9294b3942850f8e1ca739cb9dd7",
12 "mtime_ns": 1790741247924820969
13 }
14]
corpus/cross-container/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/cross-container/keys/before-read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-407884AD",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/cross-container/keys/before-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="{BD04394D-41E9-42B7-8E12-25B526246EBB}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T04:12:22.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T04:12:22.000Z" color="#8AA8E4"><one:Page ID="{662C0B99-1953-0259-3E22-0D4FD67A2F2F}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1"/></one:Section><one:Section name="Cross" ID="{2A32BA9C-C843-0094-3B8E-C1BE99CCF187}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cross.one" lastModifiedTime="2026-09-30T04:12:22.000Z" color="#91BAAE"><one:Page ID="{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1"/><one:Page ID="{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1"/><one:Page ID="{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1"/><one:Page ID="{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/cross-container/keys/before-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="{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/before-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="{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/before-read/page-002.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="{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/before-read/page-003.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="{662C0B99-1953-0259-3E22-0D4FD67A2F2F}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:12:12.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/before-read/page-004.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="{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:12.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/before-read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/cross-container/keys/commands.jsonl created+6
...@@ -0,0 +1,6 @@
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-407884AD -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 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}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-407884AD -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 sections and 5 pages.\r\n", "stderr": "", "error": null}
6{"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/cross-container/keys/delete-a-whole-table.ahk created+15
...@@ -0,0 +1,15 @@
1ComObject("OneNote.Application").NavigateTo("{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(160, 236)
9Sleep(500)
10Send("{Home}{Right 6}")
11Sleep(300)
12Send("+{Right 49}")
13Sleep(500)
14Send("{Delete}")
15Sleep(2000)
corpus/cross-container/keys/delete-a-whole-table.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": "Delete a whole table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/keys/delete-a-whole-table.png created
Binary files /dev/null and b/corpus/cross-container/keys/delete-a-whole-table.png differ
corpus/cross-container/keys/delete-into-a-table.ahk created+15
...@@ -0,0 +1,15 @@
1ComObject("OneNote.Application").NavigateTo("{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(160, 236)
9Sleep(500)
10Send("{Home}{Right 6}")
11Sleep(300)
12Send("+{Right 11}")
13Sleep(500)
14Send("{Delete}")
15Sleep(2000)
corpus/cross-container/keys/delete-into-a-table.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": "Delete into a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/keys/delete-into-a-table.png created
Binary files /dev/null and b/corpus/cross-container/keys/delete-into-a-table.png differ
corpus/cross-container/keys/delete-out-of-a-table.ahk created+17
...@@ -0,0 +1,17 @@
1ComObject("OneNote.Application").NavigateTo("{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(250, 260)
9Sleep(500)
10Send("{Home}{Right 5}")
11Sleep(300)
12Send("+{Right 28}")
13Sleep(500)
14Send("{Delete}")
15Sleep(500)
16SendText("X")
17Sleep(2000)
corpus/cross-container/keys/delete-out-of-a-table.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": "Delete out of a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/keys/delete-out-of-a-table.png created
Binary files /dev/null and b/corpus/cross-container/keys/delete-out-of-a-table.png differ
corpus/cross-container/keys/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-407884ad", "hostname": "ONE-M6-407884AD"}
corpus/cross-container/keys/notebook/Cross.one created
Binary files /dev/null and b/corpus/cross-container/keys/notebook/Cross.one differ
corpus/cross-container/keys/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/cross-container/keys/notebook/Open Notebook.onetoc2 differ
corpus/cross-container/keys/notebook/fixture.json created+23
...@@ -0,0 +1,23 @@
1{
2 "sections": [
3 "Cross.one"
4 ],
5 "pages": [
6 {
7 "section": "Cross.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Cross.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
13 },
14 {
15 "section": "Cross.one",
16 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
17 },
18 {
19 "section": "Cross.one",
20 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
21 }
22 ]
23}
\ No newline at end of file
corpus/cross-container/keys/notebook/synthetic.one created
Binary files /dev/null and b/corpus/cross-container/keys/notebook/synthetic.one differ
corpus/cross-container/keys/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-407884AD",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/cross-container/keys/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="{BD04394D-41E9-42B7-8E12-25B526246EBB}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T04:13:11.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="synthetic" ID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T04:12:38.000Z" color="#8AA8E4"><one:Page ID="{662C0B99-1953-0259-3E22-0D4FD67A2F2F}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:12:38.000Z" pageLevel="1"/></one:Section><one:Section name="Cross" ID="{2A32BA9C-C843-0094-3B8E-C1BE99CCF187}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cross.one" lastModifiedTime="2026-09-30T04:13:11.000Z" color="#91BAAE" isCurrentlyViewed="true"><one:Page ID="{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:44.000Z" pageLevel="1"/><one:Page ID="{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:54.000Z" pageLevel="1"/><one:Page ID="{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:13:06.000Z" pageLevel="1"/><one:Page ID="{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:13:11.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/cross-container/keys/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="{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:13:11.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:13:07.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:13:11.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-09-30T04:13:11.000Z" lastModifiedTime="2026-09-30T04:13:11.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:13:11.000Z" lastModifiedTime="2026-09-30T04:13:11.000Z" objectID="{3B02875A-799A-02A4-1E69-A62970B27884}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/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="{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:13:06.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:58.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="89.83252716064453" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:13:03.000Z" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[BeforeY]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="44.5925178527832"/></one:Columns><one:Row objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{47}{B0}" lastModifiedTime="2026-09-30T04:13:03.000Z"><one:Cell lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:13:03.000Z" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:13:03.000Z" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{64}{B0}" lastModifiedTime="2026-09-30T04:13:03.000Z"><one:Cell lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:13:03.000Z" lastModifiedTime="2026-09-30T04:13:03.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:12:58.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:58.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:12:58.000Z" objectID="{B8664D4E-B2D0-0584-0F03-2F5BBF8B51AD}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/read/page-002.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="{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:12:44.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="102.3262329101562" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:12:44.000Z" lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{46}{B0}"><one:Columns><one:Column index="0" width="44.29708862304687"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{47}{B0}" lastModifiedTime="2026-09-30T04:12:44.000Z"><one:Cell lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:12:44.000Z" lastModifiedTime="2026-09-30T04:12:44.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{64}{B0}" lastModifiedTime="2026-09-30T04:12:41.000Z"><one:Cell lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:41.000Z" objectID="{AA43C236-002A-0180-20F5-3FE54195A1D4}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/read/page-003.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="{662C0B99-1953-0259-3E22-0D4FD67A2F2F}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:12:39.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{09A216AB-1B4E-01A9-1A7F-2D0528F466A6}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/read/page-004.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="{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:58.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:49.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:12:54.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="94.84369659423828" height="48.80315780639648"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:49.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:54.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:12:54.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{47}{B0}" lastModifiedTime="2026-09-30T04:12:54.000Z"><one:Cell lastModifiedTime="2026-09-30T04:12:49.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:12:49.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:12:54.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:12:54.000Z" lastModifiedTime="2026-09-30T04:12:54.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[AlphaX]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:12:53.000Z" lastModifiedTime="2026-09-30T04:12:53.000Z" objectID="{42BA3BF2-5138-0AF3-1575-C69FF963F4F1}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[ text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/keys/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/cross-container/keys/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/cross-container/native/notebook",
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/cross-container/keys/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/cross-container/keys/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/cross-container/keys/source.json created+26
...@@ -0,0 +1,26 @@
1[
2 {
3 "path": "Cross.one",
4 "bytes": 29944,
5 "sha256": "4610b5a0fe24c60532f34b804f7c8f4e8117d408683bfc5a34bec3ef8e011dc9",
6 "mtime_ns": 1790740956459211068
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 3900,
11 "sha256": "a47b71ad4f6ab62f9b36a503047304ef787ba51344a52f4ca6b0f9de69912c4a",
12 "mtime_ns": 1790740956459627153
13 },
14 {
15 "path": "fixture.json",
16 "bytes": 4461,
17 "sha256": "f5b2f03775b73d8359d05549134e795cb66f8930990922495003add1a786821c",
18 "mtime_ns": 1790740956454514170
19 },
20 {
21 "path": "synthetic.one",
22 "bytes": 3348,
23 "sha256": "2500c5736a2d146b6355e15247705cf3605c465f95d28eb2d74477f4d7b57806",
24 "mtime_ns": 1790740956459996238
25 }
26]
corpus/cross-container/keys/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/cross-container/keys/type-over-a-row.ahk created+15
...@@ -0,0 +1,15 @@
1ComObject("OneNote.Application").NavigateTo("{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(160, 236)
9Sleep(500)
10Send("{Home}{Right 6}")
11Sleep(300)
12Send("+{Right 30}")
13Sleep(500)
14SendText("Y")
15Sleep(2000)
corpus/cross-container/keys/type-over-a-row.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": "Type over a row - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/cross-container/keys/type-over-a-row.png created
Binary files /dev/null and b/corpus/cross-container/keys/type-over-a-row.png differ
corpus/cross-container/native/commands.jsonl created+4
...@@ -0,0 +1,4 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-13E867D1", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-13E867D1 -ExpectedPages 5 -UseCurrentCache", "exit": 0, "stdout": "Read 2 sections and 5 pages.\r\n", "stderr": "", "error": null}
4{"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/cross-container/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-13e867d1", "hostname": "ONE-M6-13E867D1"}
corpus/cross-container/native/notebook/Cross.one created
Binary files /dev/null and b/corpus/cross-container/native/notebook/Cross.one differ
corpus/cross-container/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/cross-container/native/notebook/Open Notebook.onetoc2 differ
corpus/cross-container/native/notebook/fixture.json created+23
...@@ -0,0 +1,23 @@
1{
2 "sections": [
3 "Cross.one"
4 ],
5 "pages": [
6 {
7 "section": "Cross.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Cross.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
13 },
14 {
15 "section": "Cross.one",
16 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
17 },
18 {
19 "section": "Cross.one",
20 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"100\"/><one:Column index=\"1\" width=\"100\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
21 }
22 ]
23}
\ No newline at end of file
corpus/cross-container/native/notebook/synthetic.one created
Binary files /dev/null and b/corpus/cross-container/native/notebook/synthetic.one differ
corpus/cross-container/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-13E867D1",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/cross-container/native/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="{97C6326B-9D80-4F7A-B689-5E2569022ECE}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T04:02:03.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{0F23B2B6-CF9C-0F06-3ACD-7AC6284FC1CB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T04:02:03.000Z" color="#8AA8E4"><one:Page ID="{60ADAF84-CD81-0CF6-1E90-5A8CD6C18842}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1"/></one:Section><one:Section name="Cross" ID="{009F283D-EC70-0E20-3F81-3E2DABDE01F3}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cross.one" lastModifiedTime="2026-09-30T04:02:03.000Z" color="#91BAAE"><one:Page ID="{7F3307AD-07D2-0C91-2BEB-F4DFDB8DCB47}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1"/><one:Page ID="{860155B6-4E08-06B1-34C5-C36E10F617FC}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1"/><one:Page ID="{69DA4A0D-B650-0F4E-0A65-28738624A45A}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1"/><one:Page ID="{0EE35DB8-1595-0718-0A7A-949AF7BB7D46}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/cross-container/native/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="{0EE35DB8-1595-0718-0A7A-949AF7BB7D46}{1}{B0}" name="Delete a whole table" dateTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete a whole table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{11AF15FB-5DA9-0C10-1A66-59BA42A088F0}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/native/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="{60ADAF84-CD81-0CF6-1E90-5A8CD6C18842}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T04:02:03.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{0F23B2B6-CF9C-0F06-3ACD-7AC6284FC1CB}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{0F23B2B6-CF9C-0F06-3ACD-7AC6284FC1CB}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/native/read/page-002.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="{69DA4A0D-B650-0F4E-0A65-28738624A45A}{1}{B0}" name="Type over a row" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Type over a row]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:13.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{92CBDFEF-96E3-0B30-0B0C-D0C88D99A1D9}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/native/read/page-003.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="{7F3307AD-07D2-0C91-2BEB-F4DFDB8DCB47}{1}{B0}" name="Delete into a table" dateTime="2026-09-30T04:01:10.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete into a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{80EE5097-2419-0F34-24FA-C076738751A0}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/native/read/page-004.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="{860155B6-4E08-06B1-34C5-C36E10F617FC}{1}{B0}" name="Delete out of a table" dateTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:02:03.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete out of a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="107.6328353881836" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before text]]></one:T></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{45}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{46}{B0}"><one:Columns><one:Column index="0" width="49.60369873046874"/><one:Column index="1" width="49.89913558959961"/></one:Columns><one:Row objectID="{6817A953-750B-0447-117A-390CCB710485}{47}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpha two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{6817A953-750B-0447-117A-390CCB710485}{64}{B0}" lastModifiedTime="2026-09-30T04:01:13.000Z"><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta one]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T04:01:13.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Beta two]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T04:01:12.000Z" lastModifiedTime="2026-09-30T04:01:12.000Z" objectID="{6817A953-750B-0447-117A-390CCB710485}{81}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After text]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/cross-container/native/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/cross-container/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/unsup-lab/cross-input/notebook",
3 "expected_pages": 5,
4 "author": "tools/native/pages.ps1",
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 "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/cross-container/native/scripts/author.ps1 created+42
...@@ -0,0 +1,42 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
6$inputs = Join-Path $Root 'inputs'
7New-Item -ItemType Directory -Path $inputs | Out-Null
8$app = New-Object -ComObject OneNote.Application
9$notebookId = ''
10try {
11 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
12 $sections = @{}
13 foreach ($path in $fixture.sections) {
14 $parts = $path.Split('/')
15 if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
16 $parent = $notebookId
17 for ($index = 0; $index -lt $parts.Count; $index++) {
18 $id = ''
19 $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
20 $parent = $id
21 }
22 $sections[$path] = $id
23 }
24 $index = 0
25 foreach ($item in $fixture.pages) {
26 $pageId = ''
27 $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
28 [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
29 $xml.DocumentElement.SetAttribute('ID', $pageId)
30 [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
31 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
32 $index++
33 }
34 $app.SyncHierarchy($notebookId)
35 $app.CloseNotebook($notebookId, $false)
36 $notebookId = ''
37} finally {
38 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
39 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
40 [GC]::Collect()
41 [GC]::WaitForPendingFinalizers()
42}
corpus/cross-container/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/cross-container/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/cross-container/native/source.json created+20
...@@ -0,0 +1,20 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
6 "mtime_ns": 1790740542526906926
7 },
8 {
9 "path": "fixture.json",
10 "bytes": 4461,
11 "sha256": "f5b2f03775b73d8359d05549134e795cb66f8930990922495003add1a786821c",
12 "mtime_ns": 1790740700488051772
13 },
14 {
15 "path": "synthetic.one",
16 "bytes": 3348,
17 "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
18 "mtime_ns": 1790740542527105843
19 }
20]
corpus/cross-container/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/custom-art/README.md created+19
...@@ -0,0 +1,19 @@
1# Tags with Snowbound's art
2
3`candidate/` is the notebook `tags_with_art_keep_it_beside_the_page` in
4`crates/snowbound/src/tags.rs` writes: one page whose paragraphs carry "Launch" (a PNG
5chosen for it, OneNote's Yellow Star, 13), "Ship it" (Snowbound's Rocket, OneNote's Plane,
6127), "Done" (the Rocket over a Blue Check Box, 3, checked) and OneNote's own "Important".
7The page stores OneNote definitions only; `.snowbound/tags.json` maps each tag's name and
8symbol to its picture in `.snowbound/tags/`, named by SHA-256.
9
10`cold/` is `tools/native_custom_art.py` in a fresh OneNote 2010 clone: its author script
11gives `.snowbound` the hidden attribute Snowbound sets (the transfer archive drops it),
12`before-read/` is the cold read, which shows each fallback symbol under the tag's name and
13no `.snowbound` section group (`before-read/page-000.png`). OneNote then edits the page
14through COM (`scripts/custom-art-edit.ps1`: new text on "Launch day", "Done" unchecked)
15and syncs; `read/` is the page after, `edited.png` its render, and `sidecar.json` what
16OneNote left of the folder: still hidden, every file as written, no table of contents.
17`tools/test_custom_art.py` and `art_outlasts_onenote_editing_the_page` check this without
18a VM. Regenerate with `SNOWBOUND_CUSTOM_ART_EXPORT` set to a new absolute directory while
19running the test, then `tools/native_custom_art.py OUTPUT COLD`.
corpus/custom-art/candidate/.snowbound/tags.json created+20
...@@ -0,0 +1,20 @@
1[
2 {
3 "name": "Launch",
4 "shape": 13,
5 "art": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
6 "mapped": 134352249571937330
7 },
8 {
9 "name": "Ship it",
10 "shape": 127,
11 "art": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
12 "mapped": 134352249571944730
13 },
14 {
15 "name": "Done",
16 "shape": 3,
17 "art": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
18 "mapped": 134352249571951950
19 }
20]
\ No newline at end of file
corpus/custom-art/candidate/.snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg created+13
...@@ -0,0 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="4" y1="0" x2="12" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#b8c4d4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="11" x2="0" y2="15.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff0a0"/><stop offset="1" stop-color="#f06010"/></linearGradient>
5 </defs>
6 <path d="M6.2 12.2C6.4 13.6 7.2 14.6 8 15.6C8.8 14.6 9.6 13.6 9.8 12.2Z" fill="url(#g1)"/>
7 <path d="M5.4 8.6L2.6 11.2V13L5.6 11.8ZM10.6 8.6L13.4 11.2V13L10.4 11.8Z" fill="#d83020" stroke="#8a1810" stroke-width="0.5" stroke-linejoin="round"/>
8 <path d="M8 0.6C10.4 2.4 11.2 5.2 11 8.4C10.9 10 10.4 11.4 9.8 12.4H6.2C5.6 11.4 5.1 10 5 8.4C4.8 5.2 5.6 2.4 8 0.6Z" fill="url(#g0)" stroke="#4a5868" stroke-width="0.6" stroke-linejoin="round"/>
9 <path d="M8 0.6C9.2 1.5 10 2.6 10.5 3.8H5.5C6 2.6 6.8 1.5 8 0.6Z" fill="#d83020"/>
10 <circle cx="8" cy="6.8" r="1.7" fill="#3a8ad8" stroke="#4a5868" stroke-width="0.6"/>
11 <circle cx="7.5" cy="6.3" r="0.6" fill="#ffffff" fill-opacity="0.8"/>
12 <path d="M8 12.4V14" stroke="#8a1810" stroke-width="0.6"/>
13</svg>
corpus/custom-art/candidate/.snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png created
Binary files /dev/null and b/corpus/custom-art/candidate/.snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png differ
corpus/custom-art/candidate/Custom art.one created
Binary files /dev/null and b/corpus/custom-art/candidate/Custom art.one differ
corpus/custom-art/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/custom-art/candidate/Open Notebook.onetoc2 differ
corpus/custom-art/cold/before-read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-FFFFC1C2",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/custom-art/cold/before-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="{A4170AF5-CAF9-41DB-9B5B-02E70EFD4C63}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T07:00:19.000Z" color="#B49EDE"><one:Section name="Custom art" ID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Custom art.one" lastModifiedTime="2026-09-30T07:00:19.000Z" color="#8AA8E4"><one:Page ID="{AD26D900-E6E3-0516-29BA-25D3963F2D38}{1}{B0}" name="Launch day" dateTime="2026-09-30T06:55:20.000Z" lastModifiedTime="2026-09-30T07:00:19.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/custom-art/cold/before-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": "Launch day - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/custom-art/cold/before-read/page-000.png created
Binary files /dev/null and b/corpus/custom-art/cold/before-read/page-000.png differ
corpus/custom-art/cold/before-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="{AD26D900-E6E3-0516-29BA-25D3963F2D38}{1}{B0}" name="Launch day" dateTime="2026-09-30T06:55:20.000Z" lastModifiedTime="2026-09-30T07:00:19.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="13" fontColor="automatic" highlightColor="none" name="Launch"/><one:TagDef index="1" type="1" symbol="127" fontColor="automatic" highlightColor="none" name="Ship it"/><one:TagDef index="2" type="2" symbol="3" fontColor="automatic" highlightColor="none" name="Done"/><one:TagDef index="3" type="3" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><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="2025-08-18T14:13:20.000Z" objectID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="121.2295303344727" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T06:55:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{24}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Launch day]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{2}{B0}" alignment="left"><one:Tag index="1" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Ship the release]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{5}{B0}" alignment="left"><one:Tag index="2" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Checked off]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{8}{B0}" alignment="left"><one:Tag index="3" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Plain OneNote tag]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/custom-art/cold/before-read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/custom-art/cold/commands.jsonl created+8
...@@ -0,0 +1,8 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2 -ExpectedPages 1 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
4{"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}
5{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\custom-art-edit.ps1 -Root C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null}
6{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
7{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
8{"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/custom-art/cold/edited.png created
Binary files /dev/null and b/corpus/custom-art/cold/edited.png differ
corpus/custom-art/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-ffffc1c2", "hostname": "ONE-M6-FFFFC1C2"}
corpus/custom-art/cold/notebook/Custom art.one created
Binary files /dev/null and b/corpus/custom-art/cold/notebook/Custom art.one differ
corpus/custom-art/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/custom-art/cold/notebook/Open Notebook.onetoc2 differ
corpus/custom-art/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-FFFFC1C2",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/custom-art/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="{A4170AF5-CAF9-41DB-9B5B-02E70EFD4C63}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T07:01:43.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="Custom art" ID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Custom art.one" lastModifiedTime="2026-09-30T07:01:43.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{AD26D900-E6E3-0516-29BA-25D3963F2D38}{1}{B0}" name="Launch day, moved to Friday" dateTime="2026-09-30T06:55:20.000Z" lastModifiedTime="2026-09-30T07:01:29.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/custom-art/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="{AD26D900-E6E3-0516-29BA-25D3963F2D38}{1}{B0}" name="Launch day, moved to Friday" dateTime="2026-09-30T06:55:20.000Z" lastModifiedTime="2026-09-30T07:01:38.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:TagDef index="0" type="0" symbol="13" fontColor="automatic" highlightColor="none" name="Launch"/><one:TagDef index="1" type="1" symbol="127" fontColor="automatic" highlightColor="none" name="Ship it"/><one:TagDef index="2" type="2" symbol="3" fontColor="automatic" highlightColor="none" name="Done"/><one:TagDef index="3" type="3" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T07:01:29.000Z" objectID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="168.0869445800781" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T07:01:29.000Z" lastModifiedTime="2026-09-30T07:01:29.000Z" objectID="{B2939960-EEBF-09DE-0783-5DADBD921B65}{24}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Launch day, moved to Friday]]></one:T></one:OE><one:OE creationTime="2026-09-30T07:01:29.000Z" lastModifiedTime="2026-09-30T07:01:29.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{2}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="1" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Ship the release]]></one:T></one:OE><one:OE creationTime="2026-09-30T07:01:29.000Z" lastModifiedTime="2026-09-30T07:01:29.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{5}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="2" completed="false" disabled="false" creationDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Checked off]]></one:T></one:OE><one:OE creationTime="2026-09-30T07:01:29.000Z" lastModifiedTime="2026-09-30T07:01:29.000Z" objectID="{32747869-BD71-0252-1A61-307BA68E6362}{8}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="3" completed="true" disabled="false" creationDate="2026-09-30T06:55:57.000Z" completionDate="2026-09-30T06:55:57.000Z"/><one:T><![CDATA[Plain OneNote tag]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/custom-art/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/custom-art/cold/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/snowbound-custom-art/candidate",
3 "expected_pages": 1,
4 "author": "/Users/clo/dev/one/tools/native/custom-art.ps1",
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 "author.ps1": "fee6de8f8db4c294d9b39f5cf6428fa6031b52b9158470a3f0aeaf82ef64bfa8",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/custom-art/cold/scripts/author.ps1 created+7
...@@ -0,0 +1,7 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5# Snowbound makes its folder hidden, which the transfer's archive does not keep.
6$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force
7$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden
corpus/custom-art/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/custom-art/cold/scripts/custom-art-edit.ps1 created+45
...@@ -0,0 +1,45 @@
1param([Parameter(Mandatory=$true)][string]$Root)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$notebook = Join-Path $Root 'notebook'
5$app = New-Object -ComObject OneNote.Application
6try {
7 $hierarchy = ''
8 $app.GetHierarchy('', 4, [ref]$hierarchy, 1)
9 [xml]$tree = $hierarchy
10 $page = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]//*[local-name()="Page"]')
11 if (-not $page) { throw 'The notebook has no page open.' }
12 $content = ''
13 $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 0, 1)
14 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
15 [xml]$xml = $content
16 # OneNote edits the tagged paragraphs: new text on one, the check box of another cleared.
17 foreach ($oe in $xml.SelectNodes('//*[local-name()="OE"]')) {
18 $text = $oe.SelectSingleNode('*[local-name()="T"]')
19 if (-not $text) { continue }
20 if ($text.InnerText -eq 'Launch day') { $text.InnerXml = '<![CDATA[Launch day, moved to Friday]]>' }
21 if ($text.InnerText -eq 'Checked off') {
22 foreach ($tag in $oe.SelectNodes('*[local-name()="Tag"]')) { $tag.SetAttribute('completed', 'false') }
23 }
24 }
25 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $xml.OuterXml, [Text.Encoding]::UTF8)
26 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false)
27 $id = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]').GetAttribute('ID')
28 $app.SyncHierarchy($id)
29 Start-Sleep -Seconds 5
30} finally {
31 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
32 [GC]::Collect()
33 [GC]::WaitForPendingFinalizers()
34}
35# What OneNote left of Snowbound's folder: its attribute, and each file with its hash.
36$folder = Get-Item -LiteralPath (Join-Path $notebook '.snowbound') -Force
37$files = @(Get-ChildItem -LiteralPath $folder.FullName -Recurse -Force | ForEach-Object {
38 $hash = $null
39 if (-not $_.PSIsContainer) { $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLower() }
40 @{ path = $_.FullName.Substring($folder.FullName.Length + 1).Replace('\', '/');
41 attributes = $_.Attributes.ToString(); sha256 = $hash }
42})
43@{ attributes = $folder.Attributes.ToString(); files = $files;
44 notebook = @(Get-ChildItem -LiteralPath $notebook -Force | ForEach-Object { $_.Name }) } |
45 ConvertTo-Json -Depth 4 | Set-Content (Join-Path $Root 'sidecar.json') -Encoding UTF8
corpus/custom-art/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/custom-art/cold/sidecar.json created+30
...@@ -0,0 +1,30 @@
1{
2 "attributes": "Hidden, Directory",
3 "files": [
4 {
5 "path": "tags",
6 "attributes": "Directory",
7 "sha256": null
8 },
9 {
10 "path": "tags.json",
11 "attributes": "Archive",
12 "sha256": "297cb1a778848dca82e207fedb0d57ca60baa19add18e5e613eeb9252020b45f"
13 },
14 {
15 "path": "tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
16 "attributes": "Archive",
17 "sha256": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9"
18 },
19 {
20 "path": "tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
21 "attributes": "Archive",
22 "sha256": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92"
23 }
24 ],
25 "notebook": [
26 ".snowbound",
27 "Custom art.one",
28 "Open Notebook.onetoc2"
29 ]
30}
corpus/custom-art/cold/source.json created+32
...@@ -0,0 +1,32 @@
1[
2 {
3 "path": ".snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
4 "bytes": 1207,
5 "sha256": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9",
6 "mtime_ns": 1790751357201384901
7 },
8 {
9 "path": ".snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
10 "bytes": 377,
11 "sha256": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92",
12 "mtime_ns": 1790751357201807401
13 },
14 {
15 "path": ".snowbound/tags.json",
16 "bytes": 493,
17 "sha256": "297cb1a778848dca82e207fedb0d57ca60baa19add18e5e613eeb9252020b45f",
18 "mtime_ns": 1790751357202100985
19 },
20 {
21 "path": "Custom art.one",
22 "bytes": 8024,
23 "sha256": "db7099a6b0663436d14e11cbff66b2b1555cdd90d29191f41bcbc606f55783d3",
24 "mtime_ns": 1790751357200828150
25 },
26 {
27 "path": "Open Notebook.onetoc2",
28 "bytes": 1612,
29 "sha256": "7ab2e99e22102099e12bec081dc1e99ada669fb7b00a596fd766ce4030060109",
30 "mtime_ns": 1790751357200618358
31 }
32]
corpus/custom-art/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/equation-join/README.md created+17
...@@ -0,0 +1,17 @@
1# Deleting between equations
2
3`lab/` holds two COM reads of a disposable OneNote 2010 lab notebook.
4`equation-seam.xml` is two equations typed with Alt+=, "aa+bb" and "cc+dd" in
5paragraphs of their own, after a selection from "aa+|" into "cc|" was deleted:
6OneNote joins them into one equation, `aa++dd`. `equation-per-paragraph.xml` is
7Alt+= over a selection across two paragraphs: each paragraph's part becomes an
8equation of its own.
9
10`candidate/` is the output of `deleting_between_equations_joins_them` in
11`crates/canvas/tests/links_equations.rs` on the page of `corpus/link-edit/candidate`:
12the same equations typed and the same selection deleted through the editor, whose
13joined equation reads back as one. `cold/` is a fresh OneNote 2010 read of it, whose
14MathML is OneNote's own. `tools/test_equation_join.py` checks this without a VM.
15Regenerate with `CANVAS_EQUATION_JOIN_EXPORT` set to a new absolute directory while
16running the test, then cold-open it with
17`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`.
corpus/equation-join/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/equation-join/candidate/Open Notebook.onetoc2 differ
corpus/equation-join/candidate/links.one created
Binary files /dev/null and b/corpus/equation-join/candidate/links.one differ
corpus/equation-join/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-5366E305 -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/equation-join/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-5366e305", "hostname": "ONE-M6-5366E305"}
corpus/equation-join/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/equation-join/cold/notebook/Open Notebook.onetoc2 differ
corpus/equation-join/cold/notebook/links.one created
Binary files /dev/null and b/corpus/equation-join/cold/notebook/links.one differ
corpus/equation-join/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-5366E305",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/equation-join/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="{5B84601D-F691-48B0-8A1A-5CCC8F7B9611}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T06:09:10.000Z" color="#B49EDE"><one:Section name="links" ID="{E2D3B7F8-22D9-048B-2B44-7F3FC5EEAA40}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-30T06:09:10.000Z" color="#8AA8E4"><one:Page ID="{43DE7171-4ACE-0BB6-3DEF-5F5F3D0D23BB}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T06:08:59.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/equation-join/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": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/equation-join/cold/read/page-000.png created
Binary files /dev/null and b/corpus/equation-join/cold/read/page-000.png differ
corpus/equation-join/cold/read/page-000.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="{43DE7171-4ACE-0BB6-3DEF-5F5F3D0D23BB}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T06:08:59.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-12T09:49:16.000Z" objectID="{E2D3B7F8-22D9-048B-2B44-7F3FC5EEAA40}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="26.32370185852051"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{E2D3B7F8-22D9-048B-2B44-7F3FC5EEAA40}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{64163B48-CC3D-0E7F-26F7-013F5A3156A5}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>a</mml:mi><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mo>+</mml:mo><mml:mi>d</mml:mi><mml:mi>d</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]-->]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-join/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/equation-join/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/join-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/equation-join/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/equation-join/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/equation-join/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "df162b4a61fdc7d40bcd8c86a57cd7b98021eb75f8d1251037aa1df03cf28f1f",
6 "mtime_ns": 1790747690894245805
7 },
8 {
9 "path": "links.one",
10 "bytes": 8176,
11 "sha256": "be93291735237f7ba9bdbeb88fee8b4a46b7269b9cca3c1ce96d69b0c5ee6096",
12 "mtime_ns": 1790747690894084845
13 }
14]
corpus/equation-join/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/equation-join/lab/equation-per-paragraph.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9CFA5C07-D067-4901-97A5-1599FC05ACC6}{1}{B0}" name="Link two paragraphs" dateTime="2026-09-30T05:36:33.000Z" lastModifiedTime="2026-09-30T05:42:25.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:36:33.000Z" lastModifiedTime="2026-09-30T05:36:33.000Z" objectID="{168782FF-D3FD-4442-845E-799ED7555A42}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Link two paragraphs]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:42:25.000Z" objectID="{168782FF-D3FD-4442-845E-799ED7555A42}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="90.04496002197265" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-09-30T05:42:25.000Z" lastModifiedTime="2026-09-30T05:42:25.000Z" objectID="{168782FF-D3FD-4442-845E-799ED7555A42}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
3style='font-family:Calibri' lang=en-US>One </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>t</mml:mi><mml:mi>w</mml:mi><mml:mi>o</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-30T05:42:25.000Z" lastModifiedTime="2026-09-30T05:42:25.000Z" objectID="{168782FF-D3FD-4442-845E-799ED7555A42}{45}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>T</mml:mi><mml:mi>h</mml:mi><mml:mi>r</mml:mi><mml:mi>e</mml:mi><mml:mi>e</mml:mi></mml:math><![endif]--><span
4style='font-family:Calibri' lang=en-US> four</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
5
corpus/equation-join/lab/equation-seam.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="{4DE16FE4-AA25-4BA5-8097-515D29395720}{1}{B0}" name="Equation seam" dateTime="2026-09-30T04:16:59.000Z" lastModifiedTime="2026-09-30T04:17:23.000Z" pageLevel="1" isCurrentlyViewed="true"><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: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-30T04:17:00.000Z" lastModifiedTime="2026-09-30T04:17:00.000Z" objectID="{3469A984-E7EF-4FB1-B45A-22F34649BA81}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Equation seam]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T04:17:23.000Z" objectID="{3469A984-E7EF-4FB1-B45A-22F34649BA81}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="85.37235260009766" height="12.89598369598389"/><one:OEChildren><one:OE creationTime="2026-09-30T04:17:00.000Z" lastModifiedTime="2026-09-30T04:17:23.000Z" objectID="{3469A984-E7EF-4FB1-B45A-22F34649BA81}{29}{B0}" alignment="left" quickStyleIndex="1" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>a</mml:mi><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mo>+</mml:mo><mml:mi>d</mml:mi><mml:mi>d</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]-->]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
3
corpus/icloud-merge/README.md created+13
...@@ -0,0 +1,13 @@
1# A conflict version merged
2
3`candidate` is `a_version_of_a_onenote_file_merges_into_it` in `crates/notebook/tests/cloud.rs`
4(`ONESTORE_ICLOUD_EXPORT`): two devices edit `conflict-page/native/initial` through a fake
5iCloud Drive. The Mac types "Mac: " at the start of the first paragraph; the iPhone, offline,
6types "iPhone: " at the same place, appends to the positioned outline and adds a page "From the
7iPhone" with a body. The iPhone's upload becomes a conflict version, which the Mac merges: the
8appended text and the new page replay, and the clashing edit becomes a conflict page.
9
10`cold` is OneNote 2010's fresh read in a disposable clone (`tools/native_runner.py`,
11`--screenshots`): both pages list, the first with its conflict mark and the yellow bar "This page
12has changes that could not be merged during synchronization", its positioned outline ending
13"(typed on the iPhone)", and "From the iPhone" holding "Written offline on the iPhone."
corpus/icloud-merge/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/icloud-merge/candidate/Open Notebook.onetoc2 differ
corpus/icloud-merge/candidate/synthetic.one created
Binary files /dev/null and b/corpus/icloud-merge/candidate/synthetic.one differ
corpus/icloud-merge/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-7D90CFD6 -ExpectedPages -1 -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/icloud-merge/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-7d90cfd6", "hostname": "ONE-M6-7D90CFD6"}
corpus/icloud-merge/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/icloud-merge/cold/notebook/Open Notebook.onetoc2 differ
corpus/icloud-merge/cold/notebook/synthetic.one created
Binary files /dev/null and b/corpus/icloud-merge/cold/notebook/synthetic.one differ
corpus/icloud-merge/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1
...@@ -0,0 +1 @@
1Fictitious attachment for native corpus.
\ No newline at end of file
corpus/icloud-merge/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-7D90CFD6",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/icloud-merge/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="{92402A15-8D1F-4429-B201-CBC2C3ABE660}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T06:40:00.000Z" color="#9595AA"><one:Section name="synthetic" ID="{3ADC0ABE-B2B5-0925-1219-C2CD54B51991}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T06:40:00.000Z" color="#8AA8E4"><one:Page ID="{1695F938-3B7E-4A34-957E-6A5CA88D058E}{1}{B0}" name="Mac: Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-05T05:11:41.000Z" pageLevel="1"/><one:Page ID="{04ED57AE-93EA-0C76-0D7D-44AD57BC559F}{1}{B0}" name="From the iPhone" dateTime="2026-09-30T06:36:39.000Z" lastModifiedTime="2026-09-30T06:40:00.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/icloud-merge/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": "From the iPhone - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/icloud-merge/cold/read/page-000.png created
Binary files /dev/null and b/corpus/icloud-merge/cold/read/page-000.png differ
corpus/icloud-merge/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="{04ED57AE-93EA-0C76-0D7D-44AD57BC559F}{1}{B0}" name="From the iPhone" dateTime="2026-09-30T06:36:39.000Z" lastModifiedTime="2026-09-30T06:40:00.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="iPhone" authorInitials="I" lastModifiedBy="iPhone" lastModifiedByInitials="I" creationTime="2026-09-30T06:36:39.000Z" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{04ED57AE-93EA-0C76-0D7D-44AD57BC559F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[From the iPhone]]></one:T></one:OE></one:Title><one:Outline author="iPhone" authorInitials="I" lastModifiedBy="iPhone" lastModifiedByInitials="I" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{C25E60F5-AA08-0349-203B-13527FAAFCAB}{1}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="173.6138458251953" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T06:36:39.000Z" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{C25E60F5-AA08-0349-203B-13527FAAFCAB}{2}{B0}" alignment="left"><one:T><![CDATA[Written offline on the iPhone.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/icloud-merge/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": "Mac: Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/icloud-merge/cold/read/page-001.png created
Binary files /dev/null and b/corpus/icloud-merge/cold/read/page-001.png differ
corpus/icloud-merge/cold/read/page-001.xml created+26
...@@ -0,0 +1,26 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1695F938-3B7E-4A34-957E-6A5CA88D058E}{1}{B0}" name="Mac: Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-05T05:11:41.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{71595184-700B-4982-9A8C-E6875711E0EF}{10}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="26.58905601501465"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{71595184-700B-4982-9A8C-E6875711E0EF}{11}{B0}" alignment="left" quickStyleIndex="0" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span
3style='font-weight:bold;font-family:Calibri' lang=en-US>Mac: Fictitious: café, </span><span
4style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
5style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
6style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
7lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-05T05:11:40.000Z" objectID="{53B47D16-28E3-4731-8714-440F97918D3B}{15}{B0}"><one:Position x="421.5118103027344" y="84.75590515136718" z="5"/><one:Size width="60.00945281982422" height="60.00944519042969"/><one:Data>AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
8URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG
9IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB
10mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ
11QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY
12BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn
13NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE
14BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ
15wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW
16ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ
17GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU
18RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87
19QQrSFUA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-05T05:11:41.000Z" objectID="{53B47D16-28E3-4731-8714-440F97918D3B}{23}{B0}"><one:Position x="504.0" y="92.23936462402343" z="7"/><one:Size width="30.01890563964844" height="45.04251861572266"/><one:Data>AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
20v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF
21CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg
22KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z
23mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ
24qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG
25AEAKABEgLMlfQdkfI0A=</one:Data></one:InkDrawing><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{27E98D91-C35E-43DF-803A-640FF31D04A6}{10}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="253.2117919921875" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:57.000Z" lastModifiedTime="2026-09-30T06:36:39.000Z" objectID="{27E98D91-C35E-43DF-803A-640FF31D04A6}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fictitious positioned outline. (typed on the iPhone)]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{1AC0E51E-25C4-4A71-B64A-CFFB1F704C28}{10}{B0}"><one:Position x="144.0" y="192.0" z="2"/><one:Size width="72.0" height="0.750005722045898"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:58.000Z" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{1AC0E51E-25C4-4A71-B64A-CFFB1F704C28}{11}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA
26AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:59.000Z" objectID="{9FDAFD7E-E3BC-49B7-93E7-A8052BA1831B}{10}{B0}"><one:Position x="264.0" y="192.0" z="3"/><one:Size width="72.00001525878906" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:59.000Z" lastModifiedTime="2026-09-05T05:06:59.000Z" objectID="{9FDAFD7E-E3BC-49B7-93E7-A8052BA1831B}{11}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\458001d8-0f1e-451f-81cb-621828fc0b39.txt" pathSource="C:\one-tests\runs\20260905-05\assets\fictitious-attachment.txt" preferredName="fictitious-attachment.txt"/></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{10}{B0}"><one:Position x="144.0" y="312.0" z="4"/><one:Size width="92.42181396484374" height="21.94773101806641"/><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{11}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{12}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{13}{B0}" lastModifiedTime="2026-09-05T05:07:00.000Z"><one:Cell lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{14}{B0}" lastModifiedByInitials="S"><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{22}{B0}" lastModifiedByInitials="S"><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-05T05:07:00.000Z" objectID="{B105842E-822C-4237-8CEF-4A5003F2785D}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Right cell]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/icloud-merge/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7",
4 "name": "fictitious-attachment.txt",
5 "object": "{9FDAFD7E-E3BC-49B7-93E7-A8052BA1831B}{11}{B0}",
6 "kind": "InsertedFile",
7 "page": "{1695F938-3B7E-4A34-957E-6A5CA88D058E}{1}{B0}",
8 "bytes": 43
9 }
10]
\ No newline at end of file
corpus/icloud-merge/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/icloud-lab/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/icloud-merge/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/icloud-merge/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/icloud-merge/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 4944,
5 "sha256": "36274ad5cc077c6b1ad51ee4c4b90d3b6798a45ce446cbfd7c9d7ac504776b28",
6 "mtime_ns": 1790473161215529077
7 },
8 {
9 "path": "synthetic.one",
10 "bytes": 48544,
11 "sha256": "8b733a304e4e3a2e3f137c188227b1fc80cb5527b04c76a941f9925262801120",
12 "mtime_ns": 1790750199560524829
13 }
14]
corpus/icloud-merge/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-pressure/README.md created+45
...@@ -0,0 +1,45 @@
1# Pressure ink
2
3OneNote 2010 draws a pen's pressure but stores only what the pen reports. With a mouse, or
4with File > Options > Advanced > Pen > "Use pen pressure sensitivity" off (the default,
5`HKCU\Software\Microsoft\Office\14.0\OneNote\Options\Pen\InkPressure`), a pen's drawing
6attributes carry IgnorePressure (`0x08003411`) and its dimension table holds X and Y. With the
7option on, the attributes leave IgnorePressure out, and a pen that reports pressure adds it.
8The lab's Windows 7 has no digitizer, so the pressure strokes here come from ISF: WPF builds
9strokes with NormalPressure (and one with X and Y tilt) and OneNote takes each through
10`UpdatePageContent` (`tools/native/ink-pressure.ps1`).
11
12- `native` is that run: page "Levels" holds two drawings of nine 24 pt strokes at pressures 0
13 to 1 in eighths, one pen reporting 0..1023 and the other 0..255; "Strokes" a ramp from no to
14 full pressure, a wave, a pen ignoring the pressure it recorded, a stroke with tilt and a
15 4 by 24 rectangular tip.
16
17What it shows, and `crates/onestore/tests/page_ink.rs` pins:
18
19- The dimension table (`0x1c00340a`) keeps every packet property the pen reported, each a
20 GUID, lower and upper limit, unit and resolution: X and Y, NormalPressure
21 (`7307502d-f9f4-4e18-b3f2-2ce1b1a3610c`), tilt and the rest. The stroke's path holds one
22 block of multi-byte first differences per dimension, in the table's order.
23- OneNote draws each point as a stamp of the pen's tip `0.25 + 1.5 * p` times its width and
24 height, where `p` is the pressure over the pen's range, `(value - lower) / (upper -
25 lower)`: a quarter of the pen at none, all of it at half, 1.75 times at full. Consecutive
26 stamps are joined by the quadrilateral across their ends. Tilt changes nothing drawn, and a
27 pen with IgnorePressure draws at its width whatever it recorded.
28
29Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen
30that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure
31from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a
32finger's stroke stays as OneNote's mouse ink.
33
34`candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in
35`page_ink.rs` (`ONESTORE_INK_PRESSURE_EXPORT`): the native file with the first drawing's
36no-pressure stroke erased, four Snowbound drawings of a 500 HIMETRIC pen below it (none, half
37and full pressure, and a ramp), and on "Strokes" the ramp moved to (72, 108) and the wave
38deleted. `cold` is a fresh OneNote 2010 read: every width as drawn above, in its PDF export
39and `read/page-*.png`.
40
41`tools/test_ink_pressure.py` checks both from the PDF exports without a VM. Regenerate with
42`tools/native_runner.py EMPTY_DIRECTORY OUTPUT --author tools/native/ink-pressure.ps1
43--collect-notebook --screenshots --pdf`, and cold-open a candidate with
44`tools/native_runner.py CANDIDATE COLD --expected-pages 2 --collect-notebook --screenshots
45--pdf`.
corpus/ink-pressure/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-pressure/candidate/Open Notebook.onetoc2 differ
corpus/ink-pressure/candidate/Pressure.one created
Binary files /dev/null and b/corpus/ink-pressure/candidate/Pressure.one differ
corpus/ink-pressure/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-F2A75FD2 -ExpectedPages 2 -Pdf -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/ink-pressure/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-f2a75fd2", "hostname": "ONE-M6-F2A75FD2"}
corpus/ink-pressure/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-pressure/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-pressure/cold/notebook/Pressure.one created
Binary files /dev/null and b/corpus/ink-pressure/cold/notebook/Pressure.one differ
corpus/ink-pressure/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-F2A75FD2",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-pressure/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="{5F97FF5D-9BB9-445B-8DD9-23F1418D7D0F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T08:24:21.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="Pressure" ID="{B639AC37-DB95-0359-1522-F6E5169BD32E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Pressure.one" lastModifiedTime="2026-09-30T08:24:21.000Z" color="#91BAAE" isCurrentlyViewed="true"><one:Page ID="{8A759C5D-900B-03B4-3EFE-DDBFA53DE19D}{1}{B0}" name="Levels" dateTime="2026-09-30T07:49:06.000Z" lastModifiedTime="2026-09-30T08:24:21.000Z" pageLevel="1"/><one:Page ID="{C90E7148-CC09-0420-073F-6848167F5492}{1}{B0}" name="Strokes" dateTime="2026-09-30T07:49:07.000Z" lastModifiedTime="2026-09-30T08:24:21.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/ink-pressure/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": "Levels - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-pressure/cold/read/page-000.pdf created
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-000.pdf differ
corpus/ink-pressure/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-000.png differ
corpus/ink-pressure/cold/read/page-000.xml created+30
...@@ -0,0 +1,30 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8A759C5D-900B-03B4-3EFE-DDBFA53DE19D}{1}{B0}" name="Levels" dateTime="2026-09-30T07:49:06.000Z" lastModifiedTime="2026-09-30T08:24:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T07:49:07.000Z" lastModifiedTime="2026-09-30T07:49:07.000Z" objectID="{E712F093-D67B-06C1-3F7A-5E2A54777F46}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Levels]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T07:49:07.000Z" objectID="{E712F093-D67B-06C1-3F7A-5E2A54777F46}{40}{B0}"><one:Position x="252.0" y="90.0" z="1"/><one:Size width="60.03781127929687" height="288.0283508300781"/><one:Data>AKkCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZBkFOAMA
3/gNkAxVGAEAKDwWCfwYMlIIAggADFUYAQAoYBYJ/BgyUgv4jQ/iNEIL8SfiUAAMVRgBAChgFgn8G
4DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a
5U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ
6gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A
7ORQAgv4Di/gOMAMVRgBA
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{E712F093-D67B-06C1-3F7A-5E2A54777F46}{39}{B0}"><one:Position x="36.0" y="126.0" z="0"/><one:Size width="60.03779220581055" height="252.0283508300781"/><one:Data>AJQCHQSQAuYFARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZGQDFUYA
9QAoYBYJ/BgyUgv4jQ/iNEIL9yfuUAAMVRgBAChkFgn8GDJSC/krz+SvQgv4Dk/gOUAMVRgBAChkF
10gn8GDJSC/nKj+cqQgv4Hk/geUAMVRgBAChkFgn8GDJSC/ppT+mlQgv4Lk/guUAMVRgBAChkFgn8G
11DJSC/sID+wgQgv4Pi/g+MAMVRgBAChkFgn8GDJSC/umz+6bQgv4Ti/hOMAMVRgBAChwFgn8GDJSC
12/wAIsf4AEWQAgv4Xi/heMAMVRgBAChwFgn8GDJSC/wAcif4AORQAgv4bi/huMAMVRgBA
13</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{C0D4516B-8836-0D19-074A-70DA85BB479F}{1}{B0}"><one:Position x="36.0" y="519.9874267578124" z="2"/><one:Size width="144.0283355712891" height="0.0283241271972656"/><one:Data>APMBHQOKAwoBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
14Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
15A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApUA4L+I0P4jQCC
16/wCcaf4BONSCAAUURgBAAAAJBAUURgAAAAASABEguqhoBLnfiUiQN9dTlXSFBAoAESAAPBmUtFDd
17AQMVRgBACgARIDabTT+39GNB
18</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{C0D4516B-8836-0D19-074A-70DA85BB479F}{3}{B0}"><one:Position x="36.0" y="555.9874267578124" z="3"/><one:Size width="144.0283355712891" height="0.0283241271972656"/><one:Data>APgBHQOmAyYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
19Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
20A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC
21/wCwQf4BYISC/guT+C5QBRRGAIAAAAkEBRRGAAAAABIAESC+dPnOv2otSZfb454We3N4CgARIAA8
22GZS0UN0BAxVGAEAKABEgNptNP7f0c0E=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{C0D4516B-8836-0D19-074A-70DA85BB479F}{5}{B0}"><one:Position x="36.0" y="591.9873657226562" z="4"/><one:Size width="144.0283355712891" height="0.0283584594726562"/><one:Data>APgBHQPCA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
23Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
24A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC
25/wDEGf4BiDSC/huL+G4wBRRGAMAAAAkEBRRGAAAAABIAESBAFhfWgEA7TLxtxXvme3CCCgARIAA8
26GZS0UN0BAxVGAEAKABEgNptNP1z6gUE=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{C0D4516B-8836-0D19-074A-70DA85BB479F}{7}{B0}"><one:Position x="36.0" y="640.0062866210937" z="5"/><one:Size width="240.0377960205078" height="0.0283584594726562"/><one:Data>AJICHQOmBUIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
27Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
28A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApzKYL+I0P4cpsW
29LFixYsWLFixYsIL/AN6R/gG9JAAAAAAAgn4YsssWWWLLFlliyyxZZYAFFEYBAAAACQQFFEYAAAAA
30EgARIDlXT31MN1lIqRuN46Oag5AKABEgADwZlLRQ3QEDFUYAQAoAESA2m00/GaaMQQ==</one:Data></one:InkDrawing></one:Page>
corpus/ink-pressure/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": "Strokes - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-pressure/cold/read/page-001.pdf created
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-001.pdf differ
corpus/ink-pressure/cold/read/page-001.png created
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-001.png differ
corpus/ink-pressure/cold/read/page-001.xml created+8
...@@ -0,0 +1,8 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C90E7148-CC09-0420-073F-6848167F5492}{1}{B0}" name="Strokes" dateTime="2026-09-30T07:49:07.000Z" lastModifiedTime="2026-09-30T08:24:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T07:49:08.000Z" lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{C616D985-4DB2-02D5-2054-7AA31046A2F8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Strokes]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T08:20:40.000Z" objectID="{C616D985-4DB2-02D5-2054-7AA31046A2F8}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="240.0378112792969" height="0.0283498764038086"/><one:Data>AGYdA5AFHAEQKgAaH4tCcEWEdYC+0EXZBAMSSBBF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
3LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{C616D985-4DB2-02D5-2054-7AA31046A2F8}{41}{B0}"><one:Position x="36.0" y="220.0" z="1"/><one:Size width="240.0377960205078" height="0.0283412933349609"/><one:Data>AGYdA5AFEAEQKgAaH4tCcEWEdYC+0EXZBAMSSBRF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
4LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{C616D985-4DB2-02D5-2054-7AA31046A2F8}{42}{B0}"><one:Position x="36.0" y="270.0" z="2"/><one:Size width="240.0377960205078" height="0.0283412933349609"/><one:Data>AJgBHQOQBRgBECoAGh+LQnBFhHWAvtBF2QQDEkgQRdQBGwIAs/5G1AEbAgCz/gUFODs8C2QZGjsL
50YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC
6/gH7+AXQAAlAAASggv6IN/o5wAAAAAAAgv5ZU/llUAAAAAAAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{C616D985-4DB2-02D5-2054-7AA31046A2F8}{43}{B0}"><one:Position x="36.0" y="320.0" z="3"/><one:Size width="150.0377960205078" height="0.0283584594726562"/><one:Data>AEkdA5gDVAEQKgAaH4tCcEWEdYC+0EXZBAMOSBBFahsCAFn/RwFG+wQFAzgLZGQDFUYAQAoVC4J/
7BgyUEIIAAIJ+sJUpKAMVRgBA
8</one:Data></one:InkDrawing></one:Page>
corpus/ink-pressure/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-pressure/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-pressure/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/ink-pressure/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-pressure/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-pressure/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "c2ac6e825447b9a3857eb9f2715f1ffbb12e1b0e091882f8b1d22b2edd28c83f",
6 "mtime_ns": 1790756440470950723
7 },
8 {
9 "path": "Pressure.one",
10 "bytes": 27360,
11 "sha256": "84c7e6af5c491b39c26e1f070056464af8cec8123e666f938a9aa066a17a60c7",
12 "mtime_ns": 1790756440470653265
13 }
14]
corpus/ink-pressure/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-pressure/native/commands.jsonl created+4
...@@ -0,0 +1,4 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-29CD6F15", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-29CD6F15 -ExpectedPages -1 -UseCurrentCache -Pdf -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
4{"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-pressure/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-29cd6f15", "hostname": "ONE-M6-29CD6F15"}
corpus/ink-pressure/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-pressure/native/notebook/Open Notebook.onetoc2 differ
corpus/ink-pressure/native/notebook/Pressure.one created
Binary files /dev/null and b/corpus/ink-pressure/native/notebook/Pressure.one differ
corpus/ink-pressure/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-29CD6F15",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-pressure/native/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="{5EAD0B23-6AA5-4ACD-86E7-D141DEF02175}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T07:49:36.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="Pressure" ID="{4956C11A-40FE-07B5-3841-9812775EC42B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Pressure.one" lastModifiedTime="2026-09-30T07:49:36.000Z" color="#91BAAE" isCurrentlyViewed="true"><one:Page ID="{751AF170-0B60-0758-139D-B348C4F8F698}{1}{B0}" name="Levels" dateTime="2026-09-30T07:49:06.000Z" lastModifiedTime="2026-09-30T07:49:28.000Z" pageLevel="1" isCurrentlyViewed="true"/><one:Page ID="{36611C65-5762-00CC-2A5C-06BF77BA4397}{1}{B0}" name="Strokes" dateTime="2026-09-30T07:49:07.000Z" lastModifiedTime="2026-09-30T07:49:28.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-pressure/native/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": "Strokes - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-pressure/native/read/page-000.pdf created
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-000.pdf differ
corpus/ink-pressure/native/read/page-000.png created
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-000.png differ
corpus/ink-pressure/native/read/page-000.xml created+11
...@@ -0,0 +1,11 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{36611C65-5762-00CC-2A5C-06BF77BA4397}{1}{B0}" name="Strokes" dateTime="2026-09-30T07:49:07.000Z" lastModifiedTime="2026-09-30T07:49:28.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T07:49:08.000Z" lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Strokes]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{39}{B0}"><one:Position x="36.0" y="90.0" z="0"/><one:Size width="240.0377960205078" height="0.0283498764038086"/><one:Data>AGYdA5AFHAEQKgAaH4tCcEWEdYC+0EXZBAMSSBBF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
3LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{40}{B0}"><one:Position x="36.0" y="150.0" z="1"/><one:Size width="240.0377960205078" height="29.99054718017578"/><one:Data>AKwBHQOMBVgBECoAGh+LQnBFhHWAvtBF2QQDEEgQRWobAgBZ/0ZqGwIAWf8FAzgLZGQDFUYAQAp2
4KYJ/ARlixYsWLFixYsWLFgCHaEh8fls1ntBoM5mcni8JgkYkUunk8oU6nUqkEQgUPjcrm87n9DnM
51lMZhcEiQIfifuG1gsRisbkcjlMnk8lj8bicNgsAhESikgkEklEmk0gkEWiUIgUDhsRjsdkcpkoD
6FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{41}{B0}"><one:Position x="36.0" y="220.0" z="2"/><one:Size width="240.0377960205078" height="0.0283412933349609"/><one:Data>AGYdA5AFEAEQKgAaH4tCcEWEdYC+0EXZBAMSSBRF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
7LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{42}{B0}"><one:Position x="36.0" y="270.0" z="3"/><one:Size width="240.0377960205078" height="0.0283412933349609"/><one:Data>AJgBHQOQBRgBECoAGh+LQnBFhHWAvtBF2QQDEkgQRdQBGwIAs/5G1AEbAgCz/gUFODs8C2QZGjsL
80YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC
9/gH7+AXQAAlAAASggv6IN/o5wAAAAAAAgv5ZU/llUAAAAAAAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:08.000Z" objectID="{3979B4A8-D6D9-0639-0D37-14547183B5FD}{43}{B0}"><one:Position x="36.0" y="320.0" z="4"/><one:Size width="150.0377960205078" height="0.0283584594726562"/><one:Data>AEkdA5gDVAEQKgAaH4tCcEWEdYC+0EXZBAMOSBBFahsCAFn/RwFG+wQFAzgLZGQDFUYAQAoVC4J/
10BgyUEIIAAIJ+sJUpKAMVRgBA
11</one:Data></one:InkDrawing></one:Page>
corpus/ink-pressure/native/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": "Levels - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-pressure/native/read/page-001.pdf created
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-001.pdf differ
corpus/ink-pressure/native/read/page-001.png created
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-001.png differ
corpus/ink-pressure/native/read/page-001.xml created+13
...@@ -0,0 +1,13 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{751AF170-0B60-0758-139D-B348C4F8F698}{1}{B0}" name="Levels" dateTime="2026-09-30T07:49:06.000Z" lastModifiedTime="2026-09-30T07:49:28.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T07:49:07.000Z" lastModifiedTime="2026-09-30T07:49:07.000Z" objectID="{187D9DBE-4D10-022D-1219-30DD35B26843}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Levels]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T07:49:07.000Z" objectID="{187D9DBE-4D10-022D-1219-30DD35B26843}{39}{B0}"><one:Position x="36.0" y="90.0" z="0"/><one:Size width="60.03779220581055" height="288.0283508300781"/><one:Data>AKUCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZGQDFUYA
3QAoPBYJ/BgyUggCCAAMVRgBAChgFgn8GDJSC/iND+I0Qgv3J+5QAAxVGAEAKGQWCfwYMlIL+SvP5
4K9CC/gOT+A5QAxVGAEAKGQWCfwYMlIL+cqP5ypCC/geT+B5QAxVGAEAKGQWCfwYMlIL+mlP6aVCC
5/guT+C5QAxVGAEAKGQWCfwYMlIL+wgP7CBCC/g+L+D4wAxVGAEAKGQWCfwYMlIL+6bP7ptCC/hOL
6+E4wAxVGAEAKHAWCfwYMlIL/AAix/gARZACC/heL+F4wAxVGAEAKHAWCfwYMlIL/AByJ/gA5FACC
7/huL+G4wAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T07:49:07.000Z" objectID="{187D9DBE-4D10-022D-1219-30DD35B26843}{40}{B0}"><one:Position x="252.0" y="90.0" z="1"/><one:Size width="60.03781127929687" height="288.0283508300781"/><one:Data>AKkCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZBkFOAMA
8/gNkAxVGAEAKDwWCfwYMlIIAggADFUYAQAoYBYJ/BgyUgv4jQ/iNEIL8SfiUAAMVRgBAChgFgn8G
9DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a
10U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ
11gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A
12ORQAgv4Di/gOMAMVRgBA
13</one:Data></one:InkDrawing></one:Page>
corpus/ink-pressure/native/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-pressure/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/snowbound-pressure/empty-notebook",
3 "expected_pages": -1,
4 "author": "tools/native/ink-pressure.ps1",
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 "author.ps1": "bff822c61e6773daba010d89000dee2326282259161a252b65df2ce8acf275c9",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/ink-pressure/native/scripts/author.ps1 created+108
...@@ -0,0 +1,108 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2# Pressure ink as OneNote 2010 keeps it: WPF writes strokes with NormalPressure (and one with
3# tilt) as ISF, and OneNote takes each through UpdatePageContent into a section of its own.
4Set-StrictMode -Version Latest
5$ErrorActionPreference = 'Stop'
6& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
7Add-Type -AssemblyName PresentationCore, WindowsBase
8$P = [System.Windows.Input.StylusPointProperties]
9$U = [System.Windows.Input.StylusPointPropertyUnit]
10
11function Info($property, $min, $max, $unit, $resolution) {
12 if ($null -eq $min) { return New-Object System.Windows.Input.StylusPointPropertyInfo($property) }
13 New-Object System.Windows.Input.StylusPointPropertyInfo($property, $min, $max, $unit, $resolution)
14}
15
16# A stroke through `points` of [x, y, pressure 0..1, extra values...] in WPF units (1/96 inch).
17function Stroke($points, $width, $height, [switch]$Ignore, [switch]$Rectangle, $infos) {
18 if (-not $infos) { $infos = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure)) }
19 $description = New-Object System.Windows.Input.StylusPointDescription(,[System.Windows.Input.StylusPointPropertyInfo[]]$infos)
20 $pressure = $infos[2]
21 $collection = New-Object System.Windows.Input.StylusPointCollection(,$description)
22 foreach ($p in $points) {
23 $extra = [int[]]@($p | Select-Object -Skip 3)
24 $collection.Add((New-Object System.Windows.Input.StylusPoint($p[0], $p[1], [single]0.5, $description, $extra)))
25 $point = $collection[$collection.Count - 1]
26 $point.SetPropertyValue([System.Windows.Input.StylusPointProperties]::NormalPressure,
27 [int][Math]::Round($pressure.Minimum + $p[2] * ($pressure.Maximum - $pressure.Minimum)))
28 $collection[$collection.Count - 1] = $point
29 }
30 $stroke = New-Object System.Windows.Ink.Stroke -ArgumentList (,$collection)
31 $stroke.DrawingAttributes.Width = $width
32 $stroke.DrawingAttributes.Height = $height
33 $stroke.DrawingAttributes.IgnorePressure = [bool]$Ignore
34 $stroke.DrawingAttributes.FitToCurve = $false
35 if ($Rectangle) { $stroke.DrawingAttributes.StylusTip = [System.Windows.Ink.StylusTip]::Rectangle }
36 $stroke
37}
38
39function Isf($strokes) {
40 $collection = New-Object System.Windows.Ink.StrokeCollection
41 foreach ($stroke in $strokes) { $collection.Add([System.Windows.Ink.Stroke]$stroke) }
42 $stream = New-Object IO.MemoryStream
43 $collection.Save($stream, $true)
44 [Convert]::ToBase64String($stream.ToArray())
45}
46
47function Line($x, $y, $pressure, $count, $step) {
48 $points = @()
49 for ($i = 0; $i -lt $count; $i++) { $points += ,@(($x + $i * $step), $y, $pressure) }
50 ,$points
51}
52
53# "Levels": one drawing of nine 24-point strokes at pressures 0 to 1 in eighths, and one of
54# the same levels on a pen reporting 0..255.
55$levels = @(); $coarse = @()
56$narrow = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure 0 255 $U::None 1))
57for ($i = 0; $i -le 8; $i++) {
58 $levels += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32
59 $coarse += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32 -infos $narrow
60}
61# "Strokes": a rising ramp, a wave, a pen ignoring the pressure it recorded, a stroke with
62# tilt, and a rectangular tip, each a drawing of its own.
63$ramp = @(); $wave = @(); $flat = @(); $tilt = @(); $block = @()
64for ($i = 0; $i -le 40; $i++) {
65 $ramp += ,@(($i * 8), 0, ($i / 40))
66 $wave += ,@(($i * 8), (20 * [Math]::Sin($i / 4)), (0.5 + 0.5 * [Math]::Sin($i / 5)))
67 $flat += ,@(($i * 8), 0, ($i / 40))
68 $tilt += ,@(($i * 8), 0, (0.2 + $i / 60), (-4500 + $i * 200), 3000)
69}
70for ($i = 0; $i -le 10; $i++) { $block += ,@(($i * 20), 0, ($i / 10)) }
71$tilted = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure),
72 (Info $P::XTiltOrientation -9000 9000 $U::Degrees 100), (Info $P::YTiltOrientation -9000 9000 $U::Degrees 100))
73$pages = [ordered]@{
74 'Levels' = @(@(36, 90, (Isf $levels)), @(252, 90, (Isf $coarse)))
75 'Strokes' = @(
76 @(36, 90, (Isf @(Stroke $ramp 8 8))),
77 @(36, 150, (Isf @(Stroke $wave 4 4))),
78 @(36, 220, (Isf @(Stroke $flat 8 8 -Ignore))),
79 @(36, 270, (Isf @(Stroke $tilt 8 8 -infos $tilted))),
80 @(36, 320, (Isf @(Stroke $block 4 24 -Rectangle))))
81}
82
83$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
84$app = New-Object -ComObject OneNote.Application
85$notebookId = ''
86try {
87 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
88 $sectionId = ''
89 $app.OpenHierarchy('Pressure.one', $notebookId, [ref]$sectionId, 3)
90 foreach ($name in $pages.Keys) {
91 $pageId = ''
92 $app.CreateNewPage($sectionId, [ref]$pageId, 0)
93 $drawings = ($pages[$name] | ForEach-Object {
94 "<one:InkDrawing><one:Position x=`"$($_[0])`" y=`"$($_[1])`"/><one:Data>$($_[2])</one:Data></one:InkDrawing>"
95 }) -join ''
96 $xml = "<?xml version=`"1.0`"?><one:Page xmlns:one=`"$namespace`" ID=`"$pageId`"><one:Title><one:OE><one:T><![CDATA[$name]]></one:T></one:OE></one:Title>$drawings</one:Page>"
97 [IO.File]::WriteAllText((Join-Path $Root "author-$name.xml"), $xml, [Text.Encoding]::UTF8)
98 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
99 }
100 $app.SyncHierarchy($notebookId)
101 $app.CloseNotebook($notebookId, $false)
102 $notebookId = ''
103} finally {
104 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
105 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
106 [GC]::Collect()
107 [GC]::WaitForPendingFinalizers()
108}
corpus/ink-pressure/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-pressure/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-pressure/native/source.json created+1
...@@ -0,0 +1 @@
1[]
corpus/ink-pressure/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/object-outline/README.md created+17
...@@ -0,0 +1,17 @@
1# Typing beside a picture or file alone in its outline
2
3`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: two pages in
4`Alone.one`, one an outline holding only a text file, the other only a picture.
5
6`keys/` is `tools/native_object_outline.py` clicking beside each object inside its
7outline in OneNote 2010 and typing "abc": OneNote adds a paragraph after the object
8holding the text, and undoing removes that paragraph again.
9
10`candidate/` is the output of `typing_beside_an_outlines_only_object_adds_a_paragraph`
11in `crates/canvas/tests/object_outlines.rs`: the editor shows each outline with a
12paragraph after its object that is stored only once typed in, and the typing, its undo
13and its redo are published as revisions; `cold/` is a fresh OneNote 2010 read of it,
14whose pages read as `keys/` does. `tools/test_object_outline.py` checks this without a
15VM. Regenerate with `SNOWBOUND_OBJECT_OUTLINE_EXPORT` set to a new absolute directory
16while running the test, then cold-open it with
17`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.
corpus/object-outline/candidate/Alone.one created
Binary files /dev/null and b/corpus/object-outline/candidate/Alone.one differ
corpus/object-outline/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/candidate/Open Notebook.onetoc2 differ
corpus/object-outline/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-9223BC24 -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/object-outline/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-9223bc24", "hostname": "ONE-M6-9223BC24"}
corpus/object-outline/cold/notebook/Alone.one created
Binary files /dev/null and b/corpus/object-outline/cold/notebook/Alone.one differ
corpus/object-outline/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/cold/notebook/Open Notebook.onetoc2 differ
corpus/object-outline/cold/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-9223BC24",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-outline/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="{98385829-1AC5-4238-85EB-1D50C2B76085}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:17:00.000Z" color="#B49EDE"><one:Section name="Alone" ID="{B00D9CCD-B1E2-0087-30C8-B5E40FA5B50C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Alone.one" lastModifiedTime="2026-09-30T05:17:00.000Z" color="#91BAAE"><one:Page ID="{066E2411-379B-069C-05F5-B4C764463F16}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:17:00.000Z" pageLevel="1"/><one:Page ID="{C2AC1CC2-B6A0-0A25-03D7-06BDFD5B5F33}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:00.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/object-outline/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": "File alone - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-outline/cold/read/page-000.png created
Binary files /dev/null and b/corpus/object-outline/cold/read/page-000.png differ
corpus/object-outline/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="{066E2411-379B-069C-05F5-B4C764463F16}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:17:00.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{0667B952-4A41-0044-27AE-6ABA17C71F9C}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:55.000Z" objectID="{0667B952-4A41-0044-27AE-6ABA17C71F9C}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="76.4277114868164"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:55.000Z" lastModifiedTime="2026-09-30T05:01:55.000Z" objectID="{0667B952-4A41-0044-27AE-6ABA17C71F9C}{24}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6f8e4bbb-cd4d-4ead-a991-c524780e5150.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\notes.txt" preferredName="notes.txt"/></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:13:22.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{2D482EAB-831B-0952-1F5D-1D3C0F1336E8}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[abc]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/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": "Picture alone - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-outline/cold/read/page-001.png created
Binary files /dev/null and b/corpus/object-outline/cold/read/page-001.png differ
corpus/object-outline/cold/read/page-001.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C2AC1CC2-B6A0-0A25-03D7-06BDFD5B5F33}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:00.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{DA900089-27F5-0834-24E6-9BCBBD721E51}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{DA900089-27F5-0834-24E6-9BCBBD721E51}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="61.42771911621094"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{DA900089-27F5-0834-24E6-9BCBBD721E51}{40}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="64.0" height="48.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
3oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
54iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:13:26.000Z" lastModifiedTime="2025-08-18T14:13:26.000Z" objectID="{2D482EAB-831B-0952-1F5D-1D3C0F1336E8}{3}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[abc]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
4 "name": "notes.txt",
5 "object": "{0667B952-4A41-0044-27AE-6ABA17C71F9C}{24}{B0}",
6 "kind": "InsertedFile",
7 "page": "{066E2411-379B-069C-05F5-B4C764463F16}{1}{B0}",
8 "bytes": 23
9 }
10]
\ No newline at end of file
corpus/object-outline/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/alone-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/object-outline/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-outline/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-outline/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Alone.one",
4 "bytes": 23784,
5 "sha256": "640df8294a0321ab33d8bea926027385c7e741ca6614190dccdbd9d094f8b499",
6 "mtime_ns": 1790745185101431771
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 1604,
11 "sha256": "25817d6bf13a331f77563bac871208b831b5b2d7b6f9a4f3e916cc672dc8617c",
12 "mtime_ns": 1790745185101625391
13 }
14]
corpus/object-outline/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/object-outline/keys/before-read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/keys/before-read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-2BB9C6C4",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-outline/keys/before-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="{A3FB7D3C-2471-498F-94C2-969FE0384526}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:16:26.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T05:16:26.000Z" color="#8AA8E4"><one:Page ID="{910AD23C-129E-02F4-2F19-F84FA2405731}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:16:26.000Z" pageLevel="1"/></one:Section><one:Section name="Alone" ID="{D7D6E9CB-870C-013D-3ECD-14FDA54AA2DB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Alone.one" lastModifiedTime="2026-09-30T05:16:26.000Z" color="#91BAAE"><one:Page ID="{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:16:26.000Z" pageLevel="1"/><one:Page ID="{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:16:26.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="fixture-data" ID="{1B65B757-73AA-4237-83CC-4BF6D9B9E4FB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\fixture-data" lastModifiedTime="2026-09-30T05:16:26.000Z"/></one:Notebook>
corpus/object-outline/keys/before-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="{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:16:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:55.000Z" lastModifiedTime="2026-09-30T05:01:55.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6f8e4bbb-cd4d-4ead-a991-c524780e5150.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\notes.txt" preferredName="notes.txt"/></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/before-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="{910AD23C-129E-02F4-2F19-F84FA2405731}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:16:26.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/before-read/page-002.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:16:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="48.00000381469727"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{40}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="64.0" height="48.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
3oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
54iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/before-read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
4 "name": "notes.txt",
5 "object": "{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}",
6 "kind": "InsertedFile",
7 "page": "{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}",
8 "bytes": 23
9 }
10]
\ No newline at end of file
corpus/object-outline/keys/commands.jsonl created+6
...@@ -0,0 +1,6 @@
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-2BB9C6C4 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 3 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}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2BB9C6C4 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
6{"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/object-outline/keys/file-alone.ahk created+13
...@@ -0,0 +1,13 @@
1ComObject("OneNote.Application").NavigateTo("{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8MouseMove(190, 260)
9Sleep(800)
10Click(236, 301)
11Sleep(500)
12SendText("abc")
13Sleep(2000)
corpus/object-outline/keys/file-alone.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": "File alone - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-outline/keys/file-alone.png created
Binary files /dev/null and b/corpus/object-outline/keys/file-alone.png differ
corpus/object-outline/keys/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-2bb9c6c4", "hostname": "ONE-M6-2BB9C6C4"}
corpus/object-outline/keys/notebook/Alone.one created
Binary files /dev/null and b/corpus/object-outline/keys/notebook/Alone.one differ
corpus/object-outline/keys/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/keys/notebook/Open Notebook.onetoc2 differ
corpus/object-outline/keys/notebook/fixture-data/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/keys/notebook/fixture-data/Open Notebook.onetoc2 differ
corpus/object-outline/keys/notebook/fixture-data/notes.txt created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/keys/notebook/fixture.json created+15
...@@ -0,0 +1,15 @@
1{
2 "sections": [
3 "Alone.one"
4 ],
5 "pages": [
6 {
7 "section": "Alone.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:InsertedFile pathSource=\"__ASSETS__\\notes.txt\" preferredName=\"notes.txt\"/></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Alone.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:Image format=\"png\"><one:Size width=\"64\" height=\"48\"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7AoYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8hiZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline></one:Page>"
13 }
14 ]
15}
\ No newline at end of file
corpus/object-outline/keys/notebook/synthetic.one created
Binary files /dev/null and b/corpus/object-outline/keys/notebook/synthetic.one differ
corpus/object-outline/keys/picture-alone.ahk created+13
...@@ -0,0 +1,13 @@
1ComObject("OneNote.Application").NavigateTo("{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8MouseMove(190, 260)
9Sleep(800)
10Click(238, 282)
11Sleep(500)
12SendText("abc")
13Sleep(2000)
corpus/object-outline/keys/picture-alone.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": "Picture alone - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-outline/keys/picture-alone.png created
Binary files /dev/null and b/corpus/object-outline/keys/picture-alone.png differ
corpus/object-outline/keys/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/keys/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-2BB9C6C4",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-outline/keys/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="{A3FB7D3C-2471-498F-94C2-969FE0384526}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:17:27.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="synthetic" ID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T05:17:08.000Z" color="#8AA8E4"><one:Page ID="{910AD23C-129E-02F4-2F19-F84FA2405731}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:17:08.000Z" pageLevel="1"/></one:Section><one:Section name="Alone" ID="{D7D6E9CB-870C-013D-3ECD-14FDA54AA2DB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Alone.one" lastModifiedTime="2026-09-30T05:17:27.000Z" color="#91BAAE" isCurrentlyViewed="true"><one:Page ID="{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:17:18.000Z" pageLevel="1"/><one:Page ID="{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:27.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section><one:SectionGroup name="fixture-data" ID="{1B65B757-73AA-4237-83CC-4BF6D9B9E4FB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\fixture-data" lastModifiedTime="2026-09-30T05:16:49.000Z"/></one:Notebook>
corpus/object-outline/keys/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="{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:17:18.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:11.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:17:14.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="76.4277114868164"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:55.000Z" lastModifiedTime="2026-09-30T05:01:55.000Z" objectID="{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6f8e4bbb-cd4d-4ead-a991-c524780e5150.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T05:17:14.000Z" lastModifiedTime="2026-09-30T05:17:14.000Z" objectID="{18B0E9D3-E1EC-4DE4-AF74-44C4D286CB90}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[abc]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/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="{910AD23C-129E-02F4-2F19-F84FA2405731}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:17:09.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{FE84CF0E-1083-0104-0B44-D8055CCE1EB8}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/read/page-002.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:27.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:17:18.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:17:23.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="61.42771911621094"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BD4B758F-111B-098E-2AE3-3AD2179D0986}{40}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="64.0" height="48.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
3oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
54iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T05:17:23.000Z" lastModifiedTime="2026-09-30T05:17:23.000Z" objectID="{9F2259ED-43D1-4A49-9D2A-6493855346C8}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[abc]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/keys/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
4 "name": "notes.txt",
5 "object": "{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}",
6 "kind": "InsertedFile",
7 "page": "{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}",
8 "bytes": 23
9 }
10]
\ No newline at end of file
corpus/object-outline/keys/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/object-outline/native/notebook",
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/object-outline/keys/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-outline/keys/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-outline/keys/source.json created+38
...@@ -0,0 +1,38 @@
1[
2 {
3 "path": "Alone.one",
4 "bytes": 20784,
5 "sha256": "5fe3413bdd11788de729b1b8b375b5174af7acfcf5624b1f13e43b19334d4c3b",
6 "mtime_ns": 1790744804347247366
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 3980,
11 "sha256": "49e914844513913cd4c3ec5e8fe3a237aa4d10a8575da1f2af1a9c3f97515656",
12 "mtime_ns": 1790744804345074117
13 },
14 {
15 "path": "fixture-data/Open Notebook.onetoc2",
16 "bytes": 3656,
17 "sha256": "e4f66353f0be442324927f1b4a158b8daad7900da2cc25f82a971605d1770a3e",
18 "mtime_ns": 1790744804346997658
19 },
20 {
21 "path": "fixture-data/notes.txt",
22 "bytes": 23,
23 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
24 "mtime_ns": 1790744804346747949
25 },
26 {
27 "path": "fixture.json",
28 "bytes": 1240,
29 "sha256": "2b40bc72f2d9aefa7235fcb8f11a905904e6df46c6caa8f25444ce2d31b82457",
30 "mtime_ns": 1790744804344731700
31 },
32 {
33 "path": "synthetic.one",
34 "bytes": 5892,
35 "sha256": "a91822e4b09999a7f57435856ea754185d0e37079db20e52a62b417017a6a8e5",
36 "mtime_ns": 1790744804347492408
37 }
38]
corpus/object-outline/keys/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/object-outline/native/commands.jsonl created+4
...@@ -0,0 +1,4 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-242DCCBE", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-242DCCBE -ExpectedPages 3 -UseCurrentCache", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
4{"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/object-outline/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-242dccbe", "hostname": "ONE-M6-242DCCBE"}
corpus/object-outline/native/notebook/Alone.one created
Binary files /dev/null and b/corpus/object-outline/native/notebook/Alone.one differ
corpus/object-outline/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/native/notebook/Open Notebook.onetoc2 differ
corpus/object-outline/native/notebook/fixture-data/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-outline/native/notebook/fixture-data/Open Notebook.onetoc2 differ
corpus/object-outline/native/notebook/fixture-data/notes.txt created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/native/notebook/fixture.json created+15
...@@ -0,0 +1,15 @@
1{
2 "sections": [
3 "Alone.one"
4 ],
5 "pages": [
6 {
7 "section": "Alone.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:InsertedFile pathSource=\"__ASSETS__\\notes.txt\" preferredName=\"notes.txt\"/></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Alone.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:Title><one:OE><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"300\" height=\"100\"/><one:OEChildren><one:OE><one:Image format=\"png\"><one:Size width=\"64\" height=\"48\"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7AoYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8hiZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline></one:Page>"
13 }
14 ]
15}
\ No newline at end of file
corpus/object-outline/native/notebook/synthetic.one created
Binary files /dev/null and b/corpus/object-outline/native/notebook/synthetic.one differ
corpus/object-outline/native/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment created+1
...@@ -0,0 +1 @@
1Plain text attachment
corpus/object-outline/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-242DCCBE",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-outline/native/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="{AC5A8595-37C1-4D5D-885C-389816EAA996}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:02:14.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{D3947493-02AF-0EB0-33B7-8508A4E96057}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T05:02:14.000Z" color="#8AA8E4"><one:Page ID="{BC1A69A1-00B2-0D40-17EA-A5425A6729DE}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:02:14.000Z" pageLevel="1"/></one:Section><one:Section name="Alone" ID="{D10FF675-4CD9-0091-1551-0A6DF26A79E7}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Alone.one" lastModifiedTime="2026-09-30T05:02:14.000Z" color="#91BAAE"><one:Page ID="{676C4EA9-CAA0-068A-206C-0B4E9989F3FD}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:02:14.000Z" pageLevel="1"/><one:Page ID="{A3AE767A-4B9B-0A33-264E-B934009493D8}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:02:14.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="fixture-data" ID="{5BCF85DA-23B8-48C6-9239-50260DEB9CF8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\fixture-data" lastModifiedTime="2026-09-30T05:02:14.000Z"/></one:Notebook>
corpus/object-outline/native/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="{676C4EA9-CAA0-068A-206C-0B4E9989F3FD}{1}{B0}" name="File alone" dateTime="2026-09-30T05:01:39.000Z" lastModifiedTime="2026-09-30T05:02:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{6765D3EA-B77A-0052-0237-D533EA08D377}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[File alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{6765D3EA-B77A-0052-0237-D533EA08D377}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:55.000Z" lastModifiedTime="2026-09-30T05:01:55.000Z" objectID="{6765D3EA-B77A-0052-0237-D533EA08D377}{24}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6f8e4bbb-cd4d-4ead-a991-c524780e5150.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\notes.txt" preferredName="notes.txt"/></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/native/read/page-001.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{A3AE767A-4B9B-0A33-264E-B934009493D8}{1}{B0}" name="Picture alone" dateTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:02:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BB926A31-DACE-0822-017F-244240BDD2BA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Picture alone]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BB926A31-DACE-0822-017F-244240BDD2BA}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="72.0" height="48.00000381469727"/><one:OEChildren><one:OE creationTime="2026-09-30T05:01:56.000Z" lastModifiedTime="2026-09-30T05:01:56.000Z" objectID="{BB926A31-DACE-0822-017F-244240BDD2BA}{40}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="64.0" height="48.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
3oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
54iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/native/read/page-002.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="{BC1A69A1-00B2-0D40-17EA-A5425A6729DE}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T05:02:14.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{D3947493-02AF-0EB0-33B7-8508A4E96057}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{D3947493-02AF-0EB0-33B7-8508A4E96057}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/object-outline/native/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
4 "name": "notes.txt",
5 "object": "{6765D3EA-B77A-0052-0237-D533EA08D377}{24}{B0}",
6 "kind": "InsertedFile",
7 "page": "{676C4EA9-CAA0-068A-206C-0B4E9989F3FD}{1}{B0}",
8 "bytes": 23
9 }
10]
\ No newline at end of file
corpus/object-outline/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/unsup-lab/alone-input/notebook",
3 "expected_pages": 3,
4 "author": "tools/native/pages.ps1",
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 "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/object-outline/native/scripts/author.ps1 created+42
...@@ -0,0 +1,42 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
6$inputs = Join-Path $Root 'inputs'
7New-Item -ItemType Directory -Path $inputs | Out-Null
8$app = New-Object -ComObject OneNote.Application
9$notebookId = ''
10try {
11 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
12 $sections = @{}
13 foreach ($path in $fixture.sections) {
14 $parts = $path.Split('/')
15 if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
16 $parent = $notebookId
17 for ($index = 0; $index -lt $parts.Count; $index++) {
18 $id = ''
19 $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
20 $parent = $id
21 }
22 $sections[$path] = $id
23 }
24 $index = 0
25 foreach ($item in $fixture.pages) {
26 $pageId = ''
27 $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
28 [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
29 $xml.DocumentElement.SetAttribute('ID', $pageId)
30 [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
31 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
32 $index++
33 }
34 $app.SyncHierarchy($notebookId)
35 $app.CloseNotebook($notebookId, $false)
36 $notebookId = ''
37} finally {
38 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
39 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
40 [GC]::Collect()
41 [GC]::WaitForPendingFinalizers()
42}
corpus/object-outline/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-outline/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-outline/native/source.json created+26
...@@ -0,0 +1,26 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
6 "mtime_ns": 1790744194106080813
7 },
8 {
9 "path": "fixture-data/notes.txt",
10 "bytes": 23,
11 "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
12 "mtime_ns": 1790744194111002128
13 },
14 {
15 "path": "fixture.json",
16 "bytes": 1240,
17 "sha256": "2b40bc72f2d9aefa7235fcb8f11a905904e6df46c6caa8f25444ce2d31b82457",
18 "mtime_ns": 1790744196600541274
19 },
20 {
21 "path": "synthetic.one",
22 "bytes": 3348,
23 "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
24 "mtime_ns": 1790744194106931525
25 }
26]
corpus/object-outline/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/printout/README.md created+19
...@@ -0,0 +1,19 @@
1# File printouts
2
3`native/` is a notebook OneNote 2010 made in the lab (Insert, File Printout of a
4two-page Word document, and a shape pasted from Word), cold-read with
5`tools/native_runner.py`. Each printout page is a picture whose PictureContainer is the
6printout's XPS package, shared by its pages, with IsPrintout, the page shown
7(DisplayedPageNumber) and OneNote's PNG rendering of that page as its
8WebPictureContainer14 (MS-ONE 2.3.98), which Snowbound draws, framed in grey as OneNote
9frames printout pages.
10
11`candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures`
12in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted
13and the deletion undone, and text typed before the citation, published as one revision.
14Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote
15showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout,
16the restored one a picture. `tools/test_printout.py` checks this without a VM.
17Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while
18running the test, then cold-open it with
19`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.
corpus/printout/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/printout/candidate/Open Notebook.onetoc2 differ
corpus/printout/candidate/Printouts.one created
Binary files /dev/null and b/corpus/printout/candidate/Printouts.one differ
corpus/printout/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-B402FE00 -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/printout/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-b402fe00", "hostname": "ONE-M6-B402FE00"}
corpus/printout/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/printout/cold/notebook/Open Notebook.onetoc2 differ
corpus/printout/cold/notebook/Printouts.one created
Binary files /dev/null and b/corpus/printout/cold/notebook/Printouts.one differ
corpus/printout/cold/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment created
Binary files /dev/null and b/corpus/printout/cold/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment differ
corpus/printout/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-B402FE00",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/printout/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="{07CD8409-983C-4130-ACB9-C4153BF32885}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#B49EDE"><one:Section name="Printouts" ID="{054C6BCE-5F57-09F2-0D51-A0B21E9C6778}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#F6B078"><one:Page ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/printout/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": "Pasted shape - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/printout/cold/read/page-000.png created
Binary files /dev/null and b/corpus/printout/cold/read/page-000.png differ
corpus/printout/cold/read/page-000.xml created+74
...@@ -0,0 +1,74 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{15FA1599-679E-03F5-0B27-750F6FE79355}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t
3XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk
4otNUTao2FcavTMWPRFKzpATUYFCQhF0nriBgVJQUtOlvOY/ry+6y+/btvt33du+ObLK79+Pc83vn
53HPPvfecB7///vsHguU1MHi3+ZIVozFbe3pu3Mb/DA7ea26/InF8kePGJBviUTgjKTE8bLQxcXxs
6dITEuuov9qBGke67899WcxdQtFh7rN03zd/09Ny8owS7I8eGp0xOAOQG/fjMVIN24dcM0tbuG40t
7ly3WXqDb2tHZ1z+gBK5S2gwbPXpqsj7FkGDQx0D6k40TpNQKeBlVI2229ja2WJouXG780iJFZB8a
86E++btPeGdfMYf+7q++7jn/SWdwXPqZ1fCLKn48zDIwKs0bEWCNj3FaH0KcnJ0LcgXpm2iS35QNV
9QHVIY3499Xmb6YuOxi/NrgU3s/Mr440u482u5F4r3mP7bynExMaEpJ6xkeboCU0JSeao+J6xD7no
10CGBnT304PyvZqB+vED3ymlUL0lDOAPhY/cXmS84NqPB7dzM729O7LSm9V4w3rwFjeQP2vlZf+NjW
11mMTmeMOlmMTGhCkjAY95vTAntTAnTSWQBxhp6OdaU0ut6QImYEcMoHszui3p3ebMq18lX5dqQnuP
12pUctQMND6JsSpuDdqbbXx0bnz0ounj89sJAHDGkAfPiT5vpzHY5szb98PvfyeUiwR7OsR/AoVBjy
13DSmvm5Tx8aT0gVGj7XrJ+GliccF0CDqWcAoR4KJZfyONabj6xJma42ccLSwCGO+RA/3+Z4RvewTM
14psRHTk1Kx79b4WPFjcOCK8z5WdkTWX4Wcf8h3Wbu2nW4Horajqc5Vy4+1nE2OAB2+rhAqx9Jyqqd
15PMNOyiHiryz6RV5Wsm8fspFa8wfSwHh7zWkYXGIiYCovam8objNpTkXLAwZ2XO3k6XtT58GGF7cA
16r9ySkrl+wFtZpJ1iDCEuam8o7Dgrj2VarwWtfiRpFlAXDwRrs/LiuYoux5VCGkb11n2n7OQYKnpZ
1704cBXCCp5ymB7bY7vWB/So5YpQPpZc/lQasrQafvkcY2w85D9TsPfjZ49y6jGBiXn61V7UpJCc5K
18adMp3iUFM5aX5UWO+5GUFqSX8THSpnNfV/211tpzg2MsHQNHvGGfLy/LL5k/Q3ojbkv6DGk4uTa8
19e1S8PobTY+V/DuDdLRG8ADgAvN/KLsVCnHEDanzliwW+Uua+QRpL5I27TzJ1jZ0GzMclrSYOoacc
20gL22adbTYvt88VPZy57P87Qdx/LeIo194qp3j4pXyVg7VTQcDALvh/fMldcCbLQ9abk7phUyYw1i
21/cbyp/Rx0fIapFpeIY1F1NqtB5nLGkb12tP7uLr2Bg9WFy70quxSiDh9g5l79asLsV8iu3H5SNtp
22bIjy6vr92HGSTQqv6MiBnRkF22Y+yb5flJu++tcL5bnNZSK9/p0jh+vOEwWYlVfV7w9ZT4jSD2hz
23nGFd7ktslww+ta1/+KWMNZjHSGO5vHpzNbOxoavfqHsvRDyaSoM6UvvwpK6f8zwzy7HzvWnVs55O
24254hDfur8i8fsMMCsK6hsQM1/lDrd2/avE2znqFRx0aN27jqOY+OsHmANFbMFW99wOyv8nO1S87W
25hhq7AzteeMvXzykjmxw22oaKUumucqlIA+YVf9zLnF+wsWGCBXbYodk7rPF1uS/SnjdOqVZVFOdM
26e1gKKyQhjeMDS19/j2CGdf1m3Xt5lwVzTEofvIxvOdAWk1i5oJzOr0GyYaBJUePukcbcvPxPf2+z
27XCOYN3z0N2w7+pZ03pqnHIApvqLwd2SQA+ztr7/i9gSLznUfsLTXbK7mMHuKhNLlsdjZeGIH1rfo
28CGelK6s+wPTqulM3SGNBhZsT1ATMbC7NSkMovX14JLccfZvAxsQKKwra10V1V0hvr/43WzcDZm6C
29SYfBPyWx3/9G3fvklwTYb77zoRyksWjGaQKqubj5JN+Y8g94nvYCLQvLiWrhhM/huuaRWnAu05ie
301205RLuQ8IJhC9JTCnh5v3EAYL/Q8gl1twHHQEaYsJ0jjY1IWlNhGoCz029E847kcWBZ05GUoTsu
31EM51Ww85bcQJ0tAAbPdiZcNB7tOWx31/1sJUzSZsTLswsBx7t0caenvb3o+pHEwwboX5EzBv+oIp
32vrzpCLUAA4tCQohf9khj15nu0dAJIW/65nX9zIGylk+YDt+x/7QrpCHQuw7WUwlsYCh3I9nPLAid
337laZhK3F6pNncCJoRJnetq+OBBoY82WVFp8PLJRwtJ4oxw0p50hDs//z+Bn6jZ8T0iLMgjIe3kq2
34hZY49zUbyP15GiY3LaCh6/lWlXaRhuOMiXX1yfuX3+4jXXNc+Pbl5pPaHSen3GZjDYv1p2e/Zka4
35gDS2MZirhD0RnGsa5QDEms5iQ0kfM7XQKASka04KM/Si9s/5SV6NAiwmG/eW6eP+E4KqtiENAf+o
364RL9wEoEwWhDeQhweZHE2mL1DYWDsiGN6G7MFuMXX4Pj+QDMzL95vN52RsiG9JftV2l4CCgTHOPk
37owAH2LGRVkungDSLlptxjd+ADZ6HhKnnL9qs8H7q8HexQ3Cb8ctzwYPzAw+wsKmYms1XenX4Y5M0
38vwobTEhjLLThYTPFLlh0+KMPiNgYZOPkw5nZ2U5MaLN06W4NHyiceNtJ5E7OLE1zAFGSif7uG7d1
39vd8Koe4jB77T9Kg48Y4coDPCeN2+M6Dr6xcA5rvRwfesRA4KSPdcv61jHnBujgUf0uwM4K07/bru
40b4UDR7HfKRXzPvg4qKERsRs9usGBexqim5PqKQfYfpVOPyGKKveF/SAMtact8vLq5ABdvsVNa11Y
412Cgi0S7guDrp5lR5xAEESKHycdEROvzRBykZgzzqhhcOOAduhY8hGiIjwnWx0ZGC9h7+NuD0cQJ8
42xQFrhJDJ6aGIMfe199Xhb33VDW8n4BxArjeiQR8XpUsxCMkCKP0bfwUTB5DMj4Zj0Mfq0ocjxCOW
43nWPin2AadgiOpTneSKNGJkbM0xFI3YUPgNkcpY1knCGImYwhA1BIL1VMMU6wnS7KSNbT58aJU2S0
44yKuokwPNcUZS0gg1ijijNqTTkwSkkaFTnURzqmRwgMktBBrVbUjnTBNE+dPER9haW0bTvIqqOHDM
45KCRqmplq0+E2pBG0DAJOU7VdHidVkc6Jkc4BJN6jFA9DyRVTBaTxH5bGBUm7pDfHS6qWA8ivSLQh
46hyZFfhdu6yDsP5zg+AxrzS4dn2oHwwkbiQNDmRVn0K8l8wUdLiAN22zhbCHlQ01yDmeipjlwOCmL
47Wd0seOz9W7VF86bS8JCCj4u1dpEGxrvSC4j+RbkZbCD3kUZM8Oxpk/EDioqzfGh3zKFJ+baZRbQn
48DYeYOIveD2IXrXgun7iDlA8w3kKTU5oeNTCGSqYhVLxcIM7C8wOkodORPpPKbf7505oec2gSv3nW
49M2yGtstpbR+PrLx0DhnhrTGJSPERmvzS6Kihhpk75LWlRXajsEcaGx5LSudQoa0zi7hpphXUKdMS
50UQtXiWO+BidxQ194PIvyo0IPrMlfzLcyNQE28p7S+TAkWFr50nxHmp0gjWl87dInSYdDpmHLaWKo
51oUwklPbhYefm2t8UQTFLQhqF4Alf82ohld6TNu+UKClyKDNUnWOnxNVEGwzqkZIqOZFpqoNFN3nG
528Xot9yVk7lHnOEOcKkzPv19QTie4kQURK6uRGDIi0qiAtKjsOAoSNPFjwmp7qmBCAReskkAYZtvX
53lj7pIo2tK6ThDN+4+llse6EhqIjK+eV891o9YNvs5Ud/xc4PIRke2dFyZJombGTKZNYZniBuiqsE
54bCS4ZHnIYUG7zXnoSqZpSHhS8LywPc3KBUu4ZAcWbAgbls7MSbLs+TzxToZMmaZqeF6YKd6YMGXJ
55Eyv4nB0osCFmUNpsTfXC47MWP5UthRj3eS1ZKwgLvX449xYCKGw8voMHJJTCYh+WAcwrFv6WTDC8
56sKZaPbwYdtuLB0ijLQQHXzucR4tnM3XLXN8WEGctRcvlJXOWlMyV3oVnSKNdhButfOsfyJpJffB8
5749J57U1JcdpptAMTTMrcLO7RY6RRGak81myqYVnHMzu/QvY0HhDHGyBd1B06GFIETyWV8Si7uLdI
58oz5S6iHXGoUTxmsoVd77PJOtz8HGvgM2mdiOInYv/lxR4nrdPBINcmSatbVt36mdh4SsS/gSiU6h
59zHkgeF/hvTOjYMe0QubAwMkCeMHgzpLXvldIo0ukdVi35QDlXsILmdgqGg5y4ZYHBquFXYaqnFLm
60/4LGrny5QHwoTEb73iKNLpHgeu3WAyxTNb4p7Di7suEAn7ll4IF11K70RyHNrC72LaoqSuCslNGa
61D+Zpx16x2t60+zizyTFzQ5Mj/56X9IVUdRjYcH7RyU56wSuC1ZSLfQvp/PGBTLPOEK5w276PWZ5b
62fI+ow8jow7NvucUDR8B2TC+E/5GVxKFs7CV6L8qsQV8iTY1i5t7w7lEk+mB9AG/k4IJKdzvgECzg
63iDEM7GVl+Z4ul92yzvdIU5ewybfvP00x4umFGJbQ5zzNMWOII8b4CQ7O5WV5sg1sF3grhTS6hDLf
64fegz5Mq0w7u4zVTaagrlgLTYhqpJmS3W1WDXotz08pK5+jhbJBIlXgoiTeQ6xRtr7sKOM8WtppDK
65/AHHNW43YhtKbHP5AWMCQnGkXeCNn4A08AbqQexvgesDt59wMZ0dHGAiq7Qci3WDn5CmLrHyrjW1
667PnX52J7Dd8D5tlXLiJbF1JqBo1WJ4DrJmXg3e6gDk7nFS+YDpvL6XFdJVS3/2Tajno4zJFJ8+in
67F8VTOJWBfw2rMvzTqOPFBcAYHW6zlhbMsLsxpRC0ds36Vabt+iYRrz5xps1yzXG0cKxilwzZYfCu
68ctSHIn8ZESsIwZ/s7CwaF4S4cHZq8fzpyhlcbh+XQCLNiMPOGM44nGq8hLW4U4oZ6pjXWcYBt2NT
69tADclvBLNyVMaZyYxBzUdj3CkYkz84gs4kMHiOxBqQJpRj0MdUB+rP7CSJDTpA68gb3xZheyBMEt
7044epnaTWHB1viZqAAKttMXoX4dCxq/hY9iNQ0QGUYMcHQl1IM/qQgxFgI+Fm04XLLlCn8tDtxps2
71/Z/SeyVisN/2KAwl6MSj4KnaxyYSIOwZG0lbwhSKzxwVb7cucuQjwnxlpRlnpv4kM82ghN9Dtiiz
72iipF2m5gABu5+oB6a0cn20TxfvBetgDXdIohQc3oigeoDaTFFMOOazV3tVk6r3bfQr5dv2EPnYwo
732Zh6M1MN+vgoVWlmKY+s9pB2HBVUffMlW1o/s7UHM/3g4D3Ks2z+pocdkZDCC2z4Tx0KlmucGDv+
74x+OwV4hgybaPieP9ufCVQqqMMv8HQ/kTlxt5peMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/printout/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": "Printout - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/printout/cold/read/page-001.png created
Binary files /dev/null and b/corpus/printout/cold/read/page-001.png differ
corpus/printout/cold/read/page-001.xml created+526
...@@ -0,0 +1,526 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{D629}"/></one:XPSFile><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-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{E4603B17-812F-0DAE-0D01-C8CAD55FB421}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="303.0612182617187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Printed Inserted from: &lt;<a
3href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{32}{B0}"><one:Position x="36.0" y="225.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
4xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr
5oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
6gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
7AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
8AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
9AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
104gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
11BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
12DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
13ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
14iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
15EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
16QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
17/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
18AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
19MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
20AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
21AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
22BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
23CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
24iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
25ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
26MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
27QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
28IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
29QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
30AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
318wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
32AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
33wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
34BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
35CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
36ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
37IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
38LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
39QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
40wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
41AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
42gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
43APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
44AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
45zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
46CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
47AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
48EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
49IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
50QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
51gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
52srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
53AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
54AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
55AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
56BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
57AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
58CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
59Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
60IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
61BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
62T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
63gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
64AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
65AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
66yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
67AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
68BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
69CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
70EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
71CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
72IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
73yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
74gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
75FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
76PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
77AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
78AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
79BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
80IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
81CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
82EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
83IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
84QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
85LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
86gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
87IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
88AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
89WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
908QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
91BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
92DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
93ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
94gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
95IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
96QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
97gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
98AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
99uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
100AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
101g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
102BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
103YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
104xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
105ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
106P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
107QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
108DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
109gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
110AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
111AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
112AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
1134gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
114BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
115DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
116ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
117iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
118EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
119QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
120/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
121AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
122MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
123AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
124AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
125BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
126CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
127iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
128ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
129MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
130QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
131IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
132QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
133AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
1348wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
135AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
136wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
137BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
138CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
139ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
140IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
141LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
142QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
143wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
144AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
145gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
146APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
147AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
148zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
149CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
150AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
151EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
152IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
153QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
154gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
155srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
156AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
157AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
158AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
159BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
160AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
161CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
162Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
163IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
164BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
165T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
166gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
167AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
168AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
169yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
170AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
171BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
172CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
173EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
174CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
175IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
176yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
177gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
178FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
179PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
180AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
181AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
182BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
183IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
184CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
185EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
186IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
187QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
188LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
189gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
190IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
191AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
192WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
1938QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
194BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
195DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
196ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
197gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
198IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
199QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
200gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
201AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
202uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
203AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
204g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
205BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
206YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
207xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
208ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
209P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
210QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
211DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
212gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
213AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
214AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
215AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
2164gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
217BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
218DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
219ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
220iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
221EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
222QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
223/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
224AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
225MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
226AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
227AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
228BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
229CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
230iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
231ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
232MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
233QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
234IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
235QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
236AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
2378wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
238AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
239wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
240BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
241CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
242ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
243IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="477.3599853515625" height="617.7600708007812"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
244xAAADsQBlSsOGwAAPt9JREFUeF7t3bmqtnm57eG9NjZnYAeGamYDdqGGJiIidoGnoGBzDKKCzSFo
245ILaIgRoaqgg2oZmJimcgW6z93+uBl8lnrapa7fPbNa4vkM9Zc85n3Nd4g8E7Z+m/PPfcc//LHwIE
246CBAgQIAAgfsE/vd9j/ZkAgQIECBAgACB/ydgkHkdECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnX
247AAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wAB
248AgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIE
249CBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQ
250IEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA
2514GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBm
252AYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD
2537OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zm
254AjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8
255ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4A
256AQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAEC
257BAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQI
258ECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAg
259YJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQ
260eQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkN
261ECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAg
262QIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECA
263AAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAAB
264AgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIE
265bhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4W
266MMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDI
267bi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4u
268wOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDj
269CRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQ
270IECAAAECBln3NfCNb3zjE5/4RCTfz372s1e+8pWRMGIQIECAAIGXmYBB9uKF/v73vz9b5Przu9/9
2717sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9
272iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg
273e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz
274b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP
275b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
2764hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
277febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
278uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
2793/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
280487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
281eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
282J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
283Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
28425cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
285rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
286Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
287Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
288I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
289b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
290Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb
291z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294
292nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH
293zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6
294/kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl
295LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf
296Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
297bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
298bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
299dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
300TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
301IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
302ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
303CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC
304BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB
305AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA
306RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR
307MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM
308skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs
309U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU
310IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI
311QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ
312IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI
313ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE
314CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC
315BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB
316AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA
317qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq
318YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY
319ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ
320avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba
321vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv
322bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub
323AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA
324gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg
325QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ
326IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI
327EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE
328MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM
329gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg
330kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk
331mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm
332CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC
333EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE
334AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA
335gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg
336QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ
337IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI
338EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE
339VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV
340AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA
341IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI
342Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV
3435t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5
344dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d
345BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB
346AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA
347AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA
348AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA
349gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg
350kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk
351BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB
352gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg
353y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy
354VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV
355ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug
356BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB
357AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA
358AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA
359AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA
360gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg
361sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis
362Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC
363Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB
364ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt
365Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN
366u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu
367JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ
368ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE
369CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC
370BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB
371AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA
372gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg
373I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI
374GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG
375WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW
376qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq
377EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE
378IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI
379ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE
380CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC
381BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB
382AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA
383gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg
384VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV
385MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM
386stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs
387tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt
3883t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3
389NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N
390gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg
391QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ
392IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI
393ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE
394CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC
395GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG
396wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw
397yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy
398TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT
399hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh
400CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC
401gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg
402QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ
403IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI
404ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE
405CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC
406qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq
407gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg
408kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk
409q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq
4108+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8
411uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u
412AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA
413AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA
414AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA
415gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg
416QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ
417yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy
418AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA
419QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ
420ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ
421KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK
422QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ
423AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA
424AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA
425AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA
426gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg
427QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ
428WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW
429BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB
430g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg
431W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW
432m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm
4333U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3
434EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E
435CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC
436BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB
437AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA
438AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA
439QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ
440ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE
441DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD
442LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL
443VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV
444CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC
445ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE
446CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC
447BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB
448AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA
449AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA
450wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw
451KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK
452GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG
4532Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2
4542ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02
455724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827
456mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m
457QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ
458IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI
459ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE
460CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC
461BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB
462jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj
463YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY
464ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ
465pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap
466QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ
467hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh
468QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ
469IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI
470ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE
471CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC
472BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB
473VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV
474wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw
475yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy
4761ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1
477eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e
4783QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3
479AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A
480AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA
481gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg
482QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ
483IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI
484ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ
485AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA
486IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI
487MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM
488FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF
489IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI
490AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA
491AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA
492gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg
493QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ
494ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI
495rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr
496AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA
497QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ
498rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr
499zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz
5007iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7
501CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C
502BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB
503AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA
504AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA
505AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA
506ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI
507CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC
508BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB
509lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl
510qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq
511BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB
512CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC
513BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB
514AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA
515AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA
516AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA
517YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY
518FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF
519DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
520bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
521bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
522dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
523TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
524IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
525ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
526CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/F9mGeSSDxi9rAAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page>
corpus/printout/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
4 "name": "two-pages.docx",
5 "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}",
6 "kind": "InsertedFile",
7 "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}",
8 "bytes": 12589
9 }
10]
\ No newline at end of file
corpus/printout/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/printout-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/printout/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/printout/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/printout/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4",
6 "mtime_ns": 1790744275764608328
7 },
8 {
9 "path": "Printouts.one",
10 "bytes": 268208,
11 "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e",
12 "mtime_ns": 1790744275764329165
13 }
14]
corpus/printout/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/printout/native/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-5A6EE6B8 -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/printout/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-5a6ee6b8", "hostname": "ONE-M6-5A6EE6B8"}
corpus/printout/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/printout/native/notebook/Open Notebook.onetoc2 differ
corpus/printout/native/notebook/Printouts.one created
Binary files /dev/null and b/corpus/printout/native/notebook/Printouts.one differ
corpus/printout/native/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment created
Binary files /dev/null and b/corpus/printout/native/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment differ
corpus/printout/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-5A6EE6B8",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/printout/native/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="{AE7F0ACF-2BFF-4521-998C-7C26AA97DBF0}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T04:05:15.000Z" color="#91BAAE"><one:Section name="Printouts" ID="{34575C19-0C7B-0E44-31E0-0BDA4F64684B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-09-30T04:05:15.000Z" color="#F6B078"><one:Page ID="{ED8A8EBE-DCA2-098E-1E77-BC1F7F058730}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{3CE10A0A-3AE2-07D6-33DC-ECD4E77EBAB6}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/printout/native/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": "Pasted shape - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/printout/native/read/page-000.png created
Binary files /dev/null and b/corpus/printout/native/read/page-000.png differ
corpus/printout/native/read/page-000.xml created+74
...@@ -0,0 +1,74 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3CE10A0A-3AE2-07D6-33DC-ECD4E77EBAB6}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{24E1224E-34B2-0443-3796-DE673E1F9C66}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{7C8548F3-99CF-0D48-26BE-048CF366C88A}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{7C8548F3-99CF-0D48-26BE-048CF366C88A}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t
3XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk
4otNUTao2FcavTMWPRFKzpATUYFCQhF0nriBgVJQUtOlvOY/ry+6y+/btvt33du+ObLK79+Pc83vn
53HPPvfecB7///vsHguU1MHi3+ZIVozFbe3pu3Mb/DA7ea26/InF8kePGJBviUTgjKTE8bLQxcXxs
6dITEuuov9qBGke67899WcxdQtFh7rN03zd/09Ny8owS7I8eGp0xOAOQG/fjMVIN24dcM0tbuG40t
7ly3WXqDb2tHZ1z+gBK5S2gwbPXpqsj7FkGDQx0D6k40TpNQKeBlVI2229ja2WJouXG780iJFZB8a
86E++btPeGdfMYf+7q++7jn/SWdwXPqZ1fCLKn48zDIwKs0bEWCNj3FaH0KcnJ0LcgXpm2iS35QNV
9QHVIY3499Xmb6YuOxi/NrgU3s/Mr440u482u5F4r3mP7bynExMaEpJ6xkeboCU0JSeao+J6xD7no
10CGBnT304PyvZqB+vED3ymlUL0lDOAPhY/cXmS84NqPB7dzM729O7LSm9V4w3rwFjeQP2vlZf+NjW
11mMTmeMOlmMTGhCkjAY95vTAntTAnTSWQBxhp6OdaU0ut6QImYEcMoHszui3p3ebMq18lX5dqQnuP
12pUctQMND6JsSpuDdqbbXx0bnz0ounj89sJAHDGkAfPiT5vpzHY5szb98PvfyeUiwR7OsR/AoVBjy
13DSmvm5Tx8aT0gVGj7XrJ+GliccF0CDqWcAoR4KJZfyONabj6xJma42ccLSwCGO+RA/3+Z4RvewTM
14psRHTk1Kx79b4WPFjcOCK8z5WdkTWX4Wcf8h3Wbu2nW4Horajqc5Vy4+1nE2OAB2+rhAqx9Jyqqd
15PMNOyiHiryz6RV5Wsm8fspFa8wfSwHh7zWkYXGIiYCovam8objNpTkXLAwZ2XO3k6XtT58GGF7cA
16r9ySkrl+wFtZpJ1iDCEuam8o7Dgrj2VarwWtfiRpFlAXDwRrs/LiuYoux5VCGkb11n2n7OQYKnpZ
1704cBXCCp5ymB7bY7vWB/So5YpQPpZc/lQasrQafvkcY2w85D9TsPfjZ49y6jGBiXn61V7UpJCc5K
18adMp3iUFM5aX5UWO+5GUFqSX8THSpnNfV/211tpzg2MsHQNHvGGfLy/LL5k/Q3ojbkv6DGk4uTa8
19e1S8PobTY+V/DuDdLRG8ADgAvN/KLsVCnHEDanzliwW+Uua+QRpL5I27TzJ1jZ0GzMclrSYOoacc
20gL22adbTYvt88VPZy57P87Qdx/LeIo194qp3j4pXyVg7VTQcDALvh/fMldcCbLQ9abk7phUyYw1i
21/cbyp/Rx0fIapFpeIY1F1NqtB5nLGkb12tP7uLr2Bg9WFy70quxSiDh9g5l79asLsV8iu3H5SNtp
22bIjy6vr92HGSTQqv6MiBnRkF22Y+yb5flJu++tcL5bnNZSK9/p0jh+vOEwWYlVfV7w9ZT4jSD2hz
23nGFd7ktslww+ta1/+KWMNZjHSGO5vHpzNbOxoavfqHsvRDyaSoM6UvvwpK6f8zwzy7HzvWnVs55O
24254hDfur8i8fsMMCsK6hsQM1/lDrd2/avE2znqFRx0aN27jqOY+OsHmANFbMFW99wOyv8nO1S87W
25hhq7AzteeMvXzykjmxw22oaKUumucqlIA+YVf9zLnF+wsWGCBXbYodk7rPF1uS/SnjdOqVZVFOdM
26e1gKKyQhjeMDS19/j2CGdf1m3Xt5lwVzTEofvIxvOdAWk1i5oJzOr0GyYaBJUePukcbcvPxPf2+z
27XCOYN3z0N2w7+pZ03pqnHIApvqLwd2SQA+ztr7/i9gSLznUfsLTXbK7mMHuKhNLlsdjZeGIH1rfo
28CGelK6s+wPTqulM3SGNBhZsT1ATMbC7NSkMovX14JLccfZvAxsQKKwra10V1V0hvr/43WzcDZm6C
29SYfBPyWx3/9G3fvklwTYb77zoRyksWjGaQKqubj5JN+Y8g94nvYCLQvLiWrhhM/huuaRWnAu05ie
301205RLuQ8IJhC9JTCnh5v3EAYL/Q8gl1twHHQEaYsJ0jjY1IWlNhGoCz029E847kcWBZ05GUoTsu
31EM51Ww85bcQJ0tAAbPdiZcNB7tOWx31/1sJUzSZsTLswsBx7t0caenvb3o+pHEwwboX5EzBv+oIp
32vrzpCLUAA4tCQohf9khj15nu0dAJIW/65nX9zIGylk+YDt+x/7QrpCHQuw7WUwlsYCh3I9nPLAid
337laZhK3F6pNncCJoRJnetq+OBBoY82WVFp8PLJRwtJ4oxw0p50hDs//z+Bn6jZ8T0iLMgjIe3kq2
34hZY49zUbyP15GiY3LaCh6/lWlXaRhuOMiXX1yfuX3+4jXXNc+Pbl5pPaHSen3GZjDYv1p2e/Zka4
35gDS2MZirhD0RnGsa5QDEms5iQ0kfM7XQKASka04KM/Si9s/5SV6NAiwmG/eW6eP+E4KqtiENAf+o
364RL9wEoEwWhDeQhweZHE2mL1DYWDsiGN6G7MFuMXX4Pj+QDMzL95vN52RsiG9JftV2l4CCgTHOPk
37owAH2LGRVkungDSLlptxjd+ADZ6HhKnnL9qs8H7q8HexQ3Cb8ctzwYPzAw+wsKmYms1XenX4Y5M0
38vwobTEhjLLThYTPFLlh0+KMPiNgYZOPkw5nZ2U5MaLN06W4NHyiceNtJ5E7OLE1zAFGSif7uG7d1
39vd8Koe4jB77T9Kg48Y4coDPCeN2+M6Dr6xcA5rvRwfesRA4KSPdcv61jHnBujgUf0uwM4K07/bru
40b4UDR7HfKRXzPvg4qKERsRs9usGBexqim5PqKQfYfpVOPyGKKveF/SAMtact8vLq5ABdvsVNa11Y
412Cgi0S7guDrp5lR5xAEESKHycdEROvzRBykZgzzqhhcOOAduhY8hGiIjwnWx0ZGC9h7+NuD0cQJ8
42xQFrhJDJ6aGIMfe199Xhb33VDW8n4BxArjeiQR8XpUsxCMkCKP0bfwUTB5DMj4Zj0Mfq0ocjxCOW
43nWPin2AadgiOpTneSKNGJkbM0xFI3YUPgNkcpY1knCGImYwhA1BIL1VMMU6wnS7KSNbT58aJU2S0
44yKuokwPNcUZS0gg1ijijNqTTkwSkkaFTnURzqmRwgMktBBrVbUjnTBNE+dPER9haW0bTvIqqOHDM
45KCRqmplq0+E2pBG0DAJOU7VdHidVkc6Jkc4BJN6jFA9DyRVTBaTxH5bGBUm7pDfHS6qWA8ivSLQh
46hyZFfhdu6yDsP5zg+AxrzS4dn2oHwwkbiQNDmRVn0K8l8wUdLiAN22zhbCHlQ01yDmeipjlwOCmL
47Wd0seOz9W7VF86bS8JCCj4u1dpEGxrvSC4j+RbkZbCD3kUZM8Oxpk/EDioqzfGh3zKFJ+baZRbQn
48DYeYOIveD2IXrXgun7iDlA8w3kKTU5oeNTCGSqYhVLxcIM7C8wOkodORPpPKbf7505oec2gSv3nW
49M2yGtstpbR+PrLx0DhnhrTGJSPERmvzS6Kihhpk75LWlRXajsEcaGx5LSudQoa0zi7hpphXUKdMS
50UQtXiWO+BidxQ194PIvyo0IPrMlfzLcyNQE28p7S+TAkWFr50nxHmp0gjWl87dInSYdDpmHLaWKo
51oUwklPbhYefm2t8UQTFLQhqF4Alf82ohld6TNu+UKClyKDNUnWOnxNVEGwzqkZIqOZFpqoNFN3nG
528Xot9yVk7lHnOEOcKkzPv19QTie4kQURK6uRGDIi0qiAtKjsOAoSNPFjwmp7qmBCAReskkAYZtvX
53lj7pIo2tK6ThDN+4+llse6EhqIjK+eV891o9YNvs5Ud/xc4PIRke2dFyZJombGTKZNYZniBuiqsE
54bCS4ZHnIYUG7zXnoSqZpSHhS8LywPc3KBUu4ZAcWbAgbls7MSbLs+TzxToZMmaZqeF6YKd6YMGXJ
55Eyv4nB0osCFmUNpsTfXC47MWP5UthRj3eS1ZKwgLvX449xYCKGw8voMHJJTCYh+WAcwrFv6WTDC8
56sKZaPbwYdtuLB0ijLQQHXzucR4tnM3XLXN8WEGctRcvlJXOWlMyV3oVnSKNdhButfOsfyJpJffB8
5749J57U1JcdpptAMTTMrcLO7RY6RRGak81myqYVnHMzu/QvY0HhDHGyBd1B06GFIETyWV8Si7uLdI
58oz5S6iHXGoUTxmsoVd77PJOtz8HGvgM2mdiOInYv/lxR4nrdPBINcmSatbVt36mdh4SsS/gSiU6h
59zHkgeF/hvTOjYMe0QubAwMkCeMHgzpLXvldIo0ukdVi35QDlXsILmdgqGg5y4ZYHBquFXYaqnFLm
60/4LGrny5QHwoTEb73iKNLpHgeu3WAyxTNb4p7Di7suEAn7ll4IF11K70RyHNrC72LaoqSuCslNGa
61D+Zpx16x2t60+zizyTFzQ5Mj/56X9IVUdRjYcH7RyU56wSuC1ZSLfQvp/PGBTLPOEK5w276PWZ5b
62fI+ow8jow7NvucUDR8B2TC+E/5GVxKFs7CV6L8qsQV8iTY1i5t7w7lEk+mB9AG/k4IJKdzvgECzg
63iDEM7GVl+Z4ul92yzvdIU5ewybfvP00x4umFGJbQ5zzNMWOII8b4CQ7O5WV5sg1sF3grhTS6hDLf
64fegz5Mq0w7u4zVTaagrlgLTYhqpJmS3W1WDXotz08pK5+jhbJBIlXgoiTeQ6xRtr7sKOM8WtppDK
65/AHHNW43YhtKbHP5AWMCQnGkXeCNn4A08AbqQexvgesDt59wMZ0dHGAiq7Qci3WDn5CmLrHyrjW1
667PnX52J7Dd8D5tlXLiJbF1JqBo1WJ4DrJmXg3e6gDk7nFS+YDpvL6XFdJVS3/2Tajno4zJFJ8+in
67F8VTOJWBfw2rMvzTqOPFBcAYHW6zlhbMsLsxpRC0ds36Vabt+iYRrz5xps1yzXG0cKxilwzZYfCu
68ctSHIn8ZESsIwZ/s7CwaF4S4cHZq8fzpyhlcbh+XQCLNiMPOGM44nGq8hLW4U4oZ6pjXWcYBt2NT
69tADclvBLNyVMaZyYxBzUdj3CkYkz84gs4kMHiOxBqQJpRj0MdUB+rP7CSJDTpA68gb3xZheyBMEt
7044epnaTWHB1viZqAAKttMXoX4dCxq/hY9iNQ0QGUYMcHQl1IM/qQgxFgI+Fm04XLLlCn8tDtxps2
71/Z/SeyVisN/2KAwl6MSj4KnaxyYSIOwZG0lbwhSKzxwVb7cucuQjwnxlpRlnpv4kM82ghN9Dtiiz
72iipF2m5gABu5+oB6a0cn20TxfvBetgDXdIohQc3oigeoDaTFFMOOazV3tVk6r3bfQr5dv2EPnYwo
732Zh6M1MN+vgoVWlmKY+s9pB2HBVUffMlW1o/s7UHM/3g4D3Ks2z+pocdkZDCC2z4Tx0KlmucGDv+
74x+OwV4hgybaPieP9ufCVQqqMMv8HQ/kTlxt5peMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{7C8548F3-99CF-0D48-26BE-048CF366C88A}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/printout/native/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": "Printout - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/printout/native/read/page-001.png created
Binary files /dev/null and b/corpus/printout/native/read/page-001.png differ
corpus/printout/native/read/page-001.xml created+527
...@@ -0,0 +1,527 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{ED8A8EBE-DCA2-098E-1E77-BC1F7F058730}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{ED8A8EBE-DCA2-098E-1E77-BC1F7F058730}{1}{D108}"/></one:XPSFile><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-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{D57B0CC0-D203-0A18-31B0-63A284A7BB12}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:33.000Z" objectID="{EA6778A6-E35F-0697-11B6-2FB222D735B1}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="274.2243041992187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{EA6778A6-E35F-0697-11B6-2FB222D735B1}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:33.000Z" objectID="{EA6778A6-E35F-0697-11B6-2FB222D735B1}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Inserted from: &lt;<a
3href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2026-09-30T03:35:36.000Z" objectID="{EA6778A6-E35F-0697-11B6-2FB222D735B1}{32}{B0}"><one:Position x="36.0" y="189.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
4xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr
5oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
6gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
7AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
8AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
9AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
104gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
11BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
12DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
13ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
14iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
15EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
16QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
17/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
18AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
19MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
20AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
21AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
22BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
23CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
24iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
25ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
26MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
27QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
28IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
29QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
30AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
318wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
32AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
33wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
34BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
35CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
36ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
37IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
38LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
39QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
40wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
41AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
42gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
43APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
44AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
45zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
46CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
47AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
48EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
49IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
50QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
51gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
52srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
53AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
54AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
55AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
56BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
57AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
58CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
59Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
60IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
61BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
62T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
63gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
64AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
65AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
66yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
67AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
68BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
69CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
70EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
71CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
72IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
73yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
74gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
75FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
76PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
77AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
78AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
79BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
80IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
81CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
82EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
83IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
84QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
85LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
86gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
87IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
88AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
89WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
908QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
91BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
92DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
93ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
94gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
95IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
96QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
97gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
98AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
99uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
100AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
101g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
102BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
103YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
104xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
105ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
106P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
107QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
108DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
109gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
110AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
111AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
112AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
1134gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
114BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
115DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
116ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
117iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
118EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
119QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
120/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
121AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
122MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
123AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
124AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
125BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
126CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
127iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
128ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
129MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
130QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
131IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
132QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
133AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
1348wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
135AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
136wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
137BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
138CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
139ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
140IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
141LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
142QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
143wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
144AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
145gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
146APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
147AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
148zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
149CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
150AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
151EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
152IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
153QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
154gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
155srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
156AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
157AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
158AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
159BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
160AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
161CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
162Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
163IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
164BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
165T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
166gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
167AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
168AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
169yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
170AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
171BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
172CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
173EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
174CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
175IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
176yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
177gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
178FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
179PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
180AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
181AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
182BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
183IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
184CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
185EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
186IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
187QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
188LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
189gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
190IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
191AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
192WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
1938QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
194BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
195DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
196ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
197gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
198IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
199QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
200gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
201AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
202uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
203AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
204g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
205BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
206YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
207xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
208ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
209P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
210QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
211DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
212gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
213AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
214AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
215AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
2164gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
217BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
218DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
219ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
220iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
221EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
222QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
223/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
224AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
225MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
226AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
227AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
228BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
229CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
230iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
231ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
232MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
233QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
234IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
235QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
236AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
2378wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
238AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
239wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
240BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
241CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
242ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
243IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" xpsFileIndex="0" originalPageNumber="1" isPrintOut="true" lastModifiedTime="2026-09-30T03:35:37.000Z" objectID="{EA6778A6-E35F-0697-11B6-2FB222D735B1}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="478.7999572753906" height="619.2000732421874"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
244xAAADsQBlSsOGwAAPt9JREFUeF7t3bmqtnm57eG9NjZnYAeGamYDdqGGJiIidoGnoGBzDKKCzSFo
245ILaIgRoaqgg2oZmJimcgW6z93+uBl8lnrapa7fPbNa4vkM9Zc85n3Nd4g8E7Z+m/PPfcc//LHwIE
246CBAgQIAAgfsE/vd9j/ZkAgQIECBAgACB/ydgkHkdECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnX
247AAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wAB
248AgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIE
249CBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQ
250IEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA
2514GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBm
252AYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD
2537OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zm
254AjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8
255ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4A
256AQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAEC
257BAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQI
258ECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAg
259YJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQ
260eQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkN
261ECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAg
262QIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECA
263AAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAAB
264AgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIE
265bhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4W
266MMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDI
267bi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4u
268wOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDj
269CRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQ
270IECAAAECBln3NfCNb3zjE5/4RCTfz372s1e+8pWRMGIQIECAAIGXmYBB9uKF/v73vz9b5Przu9/9
2717sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9
272iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg
273e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz
274b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP
275b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
2764hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
277febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
278uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
2793/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
280487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
281eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
282J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
283Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
28425cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
285rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
286Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
287Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
288I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
289b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
290Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb
291z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294
292nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH
293zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6
294/kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl
295LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf
296Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
297bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
298bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
299dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
300TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
301IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
302ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
303CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC
304BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB
305AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA
306RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR
307MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM
308skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs
309U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU
310IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI
311QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ
312IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI
313ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE
314CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC
315BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB
316AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA
317qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq
318YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY
319ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ
320avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba
321vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv
322bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub
323AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA
324gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg
325QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ
326IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI
327EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE
328MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM
329gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg
330kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk
331mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm
332CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC
333EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE
334AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA
335gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg
336QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ
337IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI
338EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE
339VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV
340AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA
341IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI
342Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV
3435t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5
344dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d
345BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB
346AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA
347AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA
348AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA
349gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg
350kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk
351BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB
352gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg
353y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy
354VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV
355ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug
356BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB
357AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA
358AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA
359AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA
360gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg
361sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis
362Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC
363Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB
364ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt
365Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN
366u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu
367JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ
368ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE
369CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC
370BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB
371AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA
372gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg
373I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI
374GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG
375WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW
376qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq
377EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE
378IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI
379ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE
380CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC
381BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB
382AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA
383gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg
384VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV
385MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM
386stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs
387tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt
3883t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3
389NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N
390gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg
391QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ
392IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI
393ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE
394CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC
395GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG
396wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw
397yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy
398TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT
399hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh
400CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC
401gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg
402QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ
403IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI
404ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE
405CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC
406qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq
407gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg
408kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk
409q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq
4108+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8
411uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u
412AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA
413AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA
414AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA
415gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg
416QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ
417yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy
418AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA
419QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ
420ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ
421KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK
422QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ
423AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA
424AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA
425AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA
426gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg
427QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ
428WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW
429BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB
430g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg
431W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW
432m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm
4333U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3
434EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E
435CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC
436BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB
437AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA
438AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA
439QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ
440ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE
441DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD
442LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL
443VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV
444CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC
445ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE
446CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC
447BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB
448AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA
449AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA
450wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw
451KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK
452GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG
4532Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2
4542ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02
455724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827
456mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m
457QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ
458IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI
459ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE
460CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC
461BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB
462jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj
463YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY
464ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ
465pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap
466QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ
467hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh
468QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ
469IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI
470ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE
471CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC
472BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB
473VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV
474wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw
475yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy
4761ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1
477eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e
4783QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3
479AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A
480AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA
481gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg
482QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ
483IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI
484ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ
485AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA
486IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI
487MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM
488FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF
489IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI
490AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA
491AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA
492gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg
493QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ
494ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI
495rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr
496AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA
497QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ
498rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr
499zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz
5007iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7
501CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C
502BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB
503AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA
504AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA
505AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA
506ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI
507CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC
508BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB
509lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl
510qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq
511BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB
512CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC
513BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB
514AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA
515AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA
516AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA
517YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY
518FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF
519DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
520bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
521bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
522dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
523TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
524IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
525ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
526CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/F9mGeSSDxi9rAAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[Second printed page.
527]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="1066.440063476562" y="1066.920043945312" width="724.9920654296874" height="300.0000305175781"/><one:OCRToken startPos="7" region="0" line="0" x="1847.956787109375" y="1066.920043945312" width="727.4495239257812" height="300.0000305175781"/><one:OCRToken startPos="15" region="0" line="0" x="2631.93115234375" y="1066.920043945312" width="481.6897277832031" height="300.0000305175781"/></one:OCRData></one:Image></one:Page>
corpus/printout/native/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
4 "name": "two-pages.docx",
5 "object": "{EA6778A6-E35F-0697-11B6-2FB222D735B1}{23}{B0}",
6 "kind": "InsertedFile",
7 "page": "{ED8A8EBE-DCA2-098E-1E77-BC1F7F058730}{1}{B0}",
8 "bytes": 12589
9 }
10]
\ No newline at end of file
corpus/printout/native/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/printout/notebook",
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/printout/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/printout/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/printout/native/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 4816,
5 "sha256": "fb4c3df57bb164e4a104f3ec15d65b00a4faf85325a2079096a577a1ff304b45",
6 "mtime_ns": 1790739438446243335
7 },
8 {
9 "path": "Printouts.one",
10 "bytes": 249304,
11 "sha256": "2d75e3cfce89a2e0a18dd97b9fbaab9636a68336c3c0493b1fd5249670f7568d",
12 "mtime_ns": 1790739438418795212
13 }
14]
corpus/printout/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/README.md+38
...@@ -32,6 +32,30 @@ was not observed):...@@ -32,6 +32,30 @@ was not observed):
32 click plays from the linked moment.32 click plays from the linked moment.
33- **Deleting** the recording removes AudioRecordingGuids from the page; the33- **Deleting** the recording removes AudioRecordingGuids from the page; the
34 notes keep their links (`native/deleted`).34 notes keep their links (`native/deleted`).
35- **Pause** while recording (the Pause button again resumes) leaves the pause
36 out of the clock; a note written while paused is not linked
37 (`native/read/paused-while-recording.xml`).
38- **See Playback** (on by default) highlights, as a selection, the note
39 linked last at or before the moment playing; the line saying when, linked at
40 0, is not highlighted (`native/read/onenote-see-playback.png`). When the
41 note playing is off screen OneNote scrolls just far enough to show it
42 (`native/read/onenote-see-playback-scrolls.png`: 60 lines between two
43 notes, played from the top of the page). A linked
44 note plays from five seconds before its moment, the default of Options,
45 Audio & Video (`native/read/onenote-audio-video-options.png`), which also
46 shows the default video profile, Windows Media Video 8 for Local Area Network
47 (256 Kbps). Control-Alt-P plays and Control-Alt-S stops; Control-Alt-A does
48 not record.
49- **Record Video** without a camera says "No camera available. Make sure the
50 camera is properly installed and connected." (`native/read/onenote-no-camera.png`).
51 Installing a virtual camera in the clone was refused, so OneNote's own video
52 recording was not observed.
53- **0x1c001cc8** is a run list of {u32 character position, u8 state} on text
54 objects, OneNote's proofing state: its page date and time and the line saying
55 when recording started always carry `00 00 00 00 03` (three recordings, all
56 pages of `native/`), typed text none or 0, 1 or 9 (a misspelling), equations
57 5. OneNote writes and rereads it itself and reads text without it, so
58 Snowbound writes it only on new pages' dates, as before.
35- **Attach File** of a .wav makes it a recording as well: identity,59- **Attach File** of a .wav makes it a recording as well: identity,
36 IRecordMedia 1 and its length (1,000 ms), listed on the page60 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 and61 (`native/attached`). MS-ONE requires this of .wma, .mp3, .wav, .wmv, .avi and
...@@ -62,4 +86,18 @@ link. Regenerate with `ONESTORE_RECORDING_EDIT_EXPORT`, then...@@ -62,4 +86,18 @@ link. Regenerate with `ONESTORE_RECORDING_EDIT_EXPORT`, then
62`tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook86`tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook
63--screenshots`.87--screenshots`.
6488
89`video/` is Record Video. `native/read/attached-video.xml` is OneNote
90attaching one file of each kind: .avi, .mpg and .wmv become recordings (kind 2,
91with their length), .mp4 and .mov plain files. OneNote played Motion JPEG AVI
92with PCM or IMA ADPCM sound, MPEG-1 and WMV 8 with pictures
93(`onenote-plays-attached-*.png`), but H.264 in AVI without them. `video/candidate`
94is `a_video_recording_stores_its_file_line_and_linked_notes_for_onenote` in
95`crates/snowbound/src/recording.rs`: two seconds of Motion JPEG at 320 by 240,
9615 a second, with 16 kHz PCM, as Snowbound records video. `video/cold` is a
97fresh OneNote 2010 read; OneNote played it from a linked note with See Playback
98highlighting it (`video/played-from-note.png`), and played what the macOS
99conversion makes of `video/camera.mov` (H.264 and AAC, as a capture session
100records) and what GStreamer recorded in a Debian 13 VM from its test sources
101(`video/onenote-plays-*.png`). Regenerate with `SNOWBOUND_VIDEO_EXPORT`.
102
65`tools/test_recording.py` checks the rows without a VM.103`tools/test_recording.py` checks the rows without a VM.
corpus/recording/native/read/attached-video.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="{09744725-D357-4CF8-9F34-3DA3DE593805}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T02:02:32.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:MediaReference mediaID="{EA372074-96AB-4BA3-BFB2-40F91879D7FA}"/><one:MediaReference mediaID="{44534D35-778D-478D-8FDA-A32ABC800FBC}"/><one:MediaReference mediaID="{EBBA4594-57F8-4C80-BCE6-F515C59179DE}"/><one:MediaReference mediaID="{6004A3B4-1B58-4E0A-8A04-82ED4B39A075}"/><one:MediaReference mediaID="{B1CE6EB4-A194-4779-9CCA-244D65C1C7C5}"/><one:MediaReference mediaID="{889BD78E-BA0F-4983-B3F7-B429CE36DECF}"/></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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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-30T02:01:55.000Z" objectID="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="658.5525512695312"/><one:OEChildren><one:OE creationTime="2026-09-30T00:18:40.000Z" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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="{870F293E-BA18-4DC2-AD14-D9AD8D2A738C}{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 creationTime="2026-09-30T02:01:53.000Z" lastModifiedTime="2026-09-30T02:01:53.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{22}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\223034f3-1321-4e90-b2da-b45ece256acf.avi" pathSource="C:\work\v\h264-aac.avi" preferredName="h264-aac.avi"><one:MediaReference mediaID="{EA372074-96AB-4BA3-BFB2-40F91879D7FA}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:53.000Z" lastModifiedTime="2026-09-30T02:01:53.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{29}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\8793aeaa-a829-4259-b3aa-87203695e589.mov" pathSource="C:\work\v\h264-aac.mov" preferredName="h264-aac.mov"/></one:OE><one:OE creationTime="2026-09-30T02:01:53.000Z" lastModifiedTime="2026-09-30T02:01:53.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{36}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\d2a791fe-5e2e-41a1-8d83-70e15d973499.mp4" pathSource="C:\work\v\h264-aac.mp4" preferredName="h264-aac.mp4"/></one:OE><one:OE creationTime="2026-09-30T02:01:53.000Z" lastModifiedTime="2026-09-30T02:01:53.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{43}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\da9a3b10-03fc-49a0-951c-96a6734d2c51.avi" pathSource="C:\work\v\h264-pcm.avi" preferredName="h264-pcm.avi"><one:MediaReference mediaID="{44534D35-778D-478D-8FDA-A32ABC800FBC}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:54.000Z" lastModifiedTime="2026-09-30T02:01:54.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{50}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\3c30d036-6524-4ae4-b1ac-96dfae1fecd1.avi" pathSource="C:\work\v\mjpeg-adpcm.avi" preferredName="mjpeg-adpcm.avi"><one:MediaReference mediaID="{EBBA4594-57F8-4C80-BCE6-F515C59179DE}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:54.000Z" lastModifiedTime="2026-09-30T02:01:54.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{57}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\1a8c03e1-aea5-4a28-8409-f40529a364f8.avi" pathSource="C:\work\v\mjpeg-pcm.avi" preferredName="mjpeg-pcm.avi"><one:MediaReference mediaID="{6004A3B4-1B58-4E0A-8A04-82ED4B39A075}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:54.000Z" lastModifiedTime="2026-09-30T02:01:54.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{64}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\25846bd0-f504-4c87-8c31-d5d4af7ed4f8.mpg" pathSource="C:\work\v\mpeg1.mpg" preferredName="mpeg1.mpg"><one:MediaReference mediaID="{B1CE6EB4-A194-4779-9CCA-244D65C1C7C5}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:54.000Z" lastModifiedTime="2026-09-30T02:01:54.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{71}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\55431565-7eb8-4120-bd90-fc7d194eed66.wmv" pathSource="C:\work\v\wmv2.wmv" preferredName="wmv2.wmv"><one:MediaReference mediaID="{889BD78E-BA0F-4983-B3F7-B429CE36DECF}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:01:55.000Z" lastModifiedTime="2026-09-30T02:01:55.000Z" objectID="{C4EBFB0F-8866-4CB7-93B9-A71C038309AD}{68}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
3
corpus/recording/native/read/onenote-audio-video-options.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-audio-video-options.png differ
corpus/recording/native/read/onenote-h264-avi-no-picture.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-h264-avi-no-picture.png differ
corpus/recording/native/read/onenote-no-camera.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-no-camera.png differ
corpus/recording/native/read/onenote-plays-attached-mjpeg-avi.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-attached-mjpeg-avi.png differ
corpus/recording/native/read/onenote-plays-attached-wmv.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-attached-wmv.png differ
corpus/recording/native/read/onenote-see-playback-scrolls.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-see-playback-scrolls.png differ
corpus/recording/native/read/onenote-see-playback.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-see-playback.png differ
corpus/recording/native/read/paused-while-recording.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="{B4DDF950-6AFB-42F2-B831-56D93A2E7837}{1}{B0}" name="Second recording" dateTime="2026-09-30T02:13:58.000Z" lastModifiedTime="2026-09-30T02:14:31.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="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></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-30T02:14:00.000Z" lastModifiedTime="2026-09-30T02:14:00.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Second recording]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T02:14:25.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="167.9803009033203"/><one:OEChildren><one:OE creationTime="2026-09-30T02:14:00.000Z" lastModifiedTime="2026-09-30T02:14:00.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{29}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Typed before]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:02.000Z" lastModifiedTime="2026-09-30T02:14:02.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{46}{B0}" alignment="left"><one:MediaIndex timeIndex="68"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\688e802c-f942-4ae3-8473-9466e98137db.wma" pathSource="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\Audio Cache\Second recording.wma" preferredName="Second recording.wma"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T02:14:02.000Z" lastModifiedTime="2026-09-30T02:14:02.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{34}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:02.000Z" lastModifiedTime="2026-09-30T02:14:02.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{36}{B0}" alignment="left" quickStyleIndex="2"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 7:14 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:02.000Z" lastModifiedTime="2026-09-30T02:14:02.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{39}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:04.000Z" lastModifiedTime="2026-09-30T02:14:04.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{41}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="2229"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaIndex><one:T><![CDATA[Speling mistaek note]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:07.000Z" lastModifiedTime="2026-09-30T02:14:07.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{47}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="5246"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaIndex><one:T><![CDATA[Another note here]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:14.000Z" lastModifiedTime="2026-09-30T02:14:14.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{49}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Typed while paused]]></one:T></one:OE><one:OE creationTime="2026-09-30T02:14:25.000Z" lastModifiedTime="2026-09-30T02:14:25.000Z" objectID="{B576F76E-437F-4CB3-9EC1-A632BEBDFB3D}{51}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="14206"><one:MediaReference mediaID="{92135DCD-166D-4E3A-8210-CB7DA5B2E3A2}"/></one:MediaIndex><one:T><![CDATA[After resuming]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
3
corpus/recording/video/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/video/candidate/Open Notebook.onetoc2 differ
corpus/recording/video/candidate/Recorded.one created
Binary files /dev/null and b/corpus/recording/video/candidate/Recorded.one differ
corpus/recording/video/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-63505C69 -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/video/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-63505c69", "hostname": "ONE-M6-63505C69"}
corpus/recording/video/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/video/cold/notebook/Open Notebook.onetoc2 differ
corpus/recording/video/cold/notebook/Recorded.one created
Binary files /dev/null and b/corpus/recording/video/cold/notebook/Recorded.one differ
corpus/recording/video/cold/read/35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94.attachment created
Binary files /dev/null and b/corpus/recording/video/cold/read/35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94.attachment differ
corpus/recording/video/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-63505C69",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recording/video/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="{E58A8D94-B6A5-462C-9FD9-0221C7592D50}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T02:35:50.000Z" color="#B49EDE"><one:Section name="Recorded" ID="{541F1BF5-BAE2-0C47-1B2F-D2E6A3BC9B22}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Recorded.one" lastModifiedTime="2026-09-30T02:35:50.000Z" color="#8AA8E4"><one:Page ID="{BA8D3BEF-1250-0DF7-044C-ACACE947896E}{1}{B0}" name="Before recording" dateTime="2026-09-30T02:32:32.000Z" lastModifiedTime="2026-09-30T02:35:50.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/recording/video/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/video/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recording/video/cold/read/page-000.png differ
corpus/recording/video/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="{BA8D3BEF-1250-0DF7-044C-ACACE947896E}{1}{B0}" name="Before recording" dateTime="2026-09-30T02:32:32.000Z" lastModifiedTime="2026-09-30T02:35:50.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="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></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-30T02:32:32.000Z" objectID="{541F1BF5-BAE2-0C47-1B2F-D2E6A3BC9B22}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="271.7191162109375" height="127.6971435546875"/><one:OEChildren><one:OE creationTime="2026-09-30T02:32:32.000Z" lastModifiedTime="2026-09-30T02:32:32.000Z" objectID="{541F1BF5-BAE2-0C47-1B2F-D2E6A3BC9B22}{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="{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{20}{B0}" alignment="left"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\3b5ce727-03dd-49d6-8636-31173d47ef9a.avi" preferredName="Recorded.avi"><one:MediaReference mediaID="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></one:MediaFile></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{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="{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{3}{B0}" alignment="left" quickStyleIndex="0"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></one:MediaIndex><one:T><![CDATA[Video 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="{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{5}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="42"><one:MediaReference mediaID="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></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="{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{15}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="87"><one:MediaReference mediaID="{0C063E32-5BB3-466D-8B0E-55B758D79719}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/video/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94",
4 "name": "Recorded.avi",
5 "object": "{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{20}{B0}",
6 "kind": "MediaFile",
7 "page": "{BA8D3BEF-1250-0DF7-044C-ACACE947896E}{1}{B0}",
8 "bytes": 266580
9 }
10]
\ No newline at end of file
corpus/recording/video/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-recording/video-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/video/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recording/video/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/recording/video/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "c38d816f868de6acaadc7f3cd6e6f6f6b8322c73cbed1ece005a260d94574c04",
6 "mtime_ns": 1790735554335753866
7 },
8 {
9 "path": "Recorded.one",
10 "bytes": 276984,
11 "sha256": "773042e6ff29189dbb1e20b5ff22cfd40078aefce5ed706d1f2f9fddf41d52fe",
12 "mtime_ns": 1790735554327711884
13 }
14]
corpus/recording/video/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/video/onenote-plays-gstreamer.png created
Binary files /dev/null and b/corpus/recording/video/onenote-plays-gstreamer.png differ
corpus/recording/video/onenote-plays-macos-conversion.png created
Binary files /dev/null and b/corpus/recording/video/onenote-plays-macos-conversion.png differ
corpus/recording/video/played-from-note.png created
Binary files /dev/null and b/corpus/recording/video/played-from-note.png differ
corpus/table-tags/README.md created+19
...@@ -0,0 +1,19 @@
1# Note tags on tables through the editor
2
3`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: three pages in
4`Tables.one`, each a table between two paragraphs, tagged To Do (unchecked) or
5Important, the last also holding a To Do-tagged table in a cell. OneNote stores a
6table's tags on the table object, and draws them in the tag column centred on it.
7
8`candidate/` is the output of `tagged_tables_draw_check_and_survive_edits` in
9`crates/canvas/tests/table_tags.rs`: on "Check a table" a click checks the table's
10box; on "Edit a tagged table" text is typed and Enter pressed in a cell; "Delete and
11undo" has all its content deleted and the deletion undone, each published as its own
12revision.
13
14`cold/` is a fresh OneNote 2010 read: the first table's To Do is completed, the edited
15table keeps its tag, and the restored tables read as OneNote authored them.
16`tools/test_table_tags.py` checks this without a VM. Regenerate with
17`SNOWBOUND_TABLE_TAGS_EXPORT` set to a new absolute directory while running the test,
18then cold-open it with
19`tools/native_runner.py OUTPUT COLD --expected-pages 3 --collect-notebook --screenshots`.
corpus/table-tags/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/table-tags/candidate/Open Notebook.onetoc2 differ
corpus/table-tags/candidate/Tables.one created
Binary files /dev/null and b/corpus/table-tags/candidate/Tables.one differ
corpus/table-tags/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-9A109FC4 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 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/table-tags/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-9a109fc4", "hostname": "ONE-M6-9A109FC4"}
corpus/table-tags/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/table-tags/cold/notebook/Open Notebook.onetoc2 differ
corpus/table-tags/cold/notebook/Tables.one created
Binary files /dev/null and b/corpus/table-tags/cold/notebook/Tables.one differ
corpus/table-tags/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-9A109FC4",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/table-tags/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="{46858C5C-985E-4609-AB41-4DA33A00B7B5}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T03:58:34.000Z" color="#B49EDE"><one:Section name="Tables" ID="{E32841B7-12EA-0B00-09CB-6598D0A96D70}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tables.one" lastModifiedTime="2026-09-30T03:58:34.000Z" color="#91BAAE"><one:Page ID="{448B217E-8685-0669-3931-A5F5EB3A1095}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1"/><one:Page ID="{E4B8C5D6-D05E-0F3B-1CF6-6FF98AB5D9CA}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1"/><one:Page ID="{9FE96B82-F974-0AAF-22DA-1F3836321E11}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/table-tags/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": "Check a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/cold/read/page-000.png created
Binary files /dev/null and b/corpus/table-tags/cold/read/page-000.png differ
corpus/table-tags/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="{448B217E-8685-0669-3931-A5F5EB3A1095}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Check a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="130.4485626220703" height="48.80315780639648"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{29}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:55:24.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{30}{B0}"><one:Columns><one:Column index="0" width="85.20857238769531"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{31}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{32}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Checked by a click]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{40}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{DD395725-2E27-07D1-08D5-F31FD974FF14}{49}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/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": "Delete and undo - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/cold/read/page-001.png created
Binary files /dev/null and b/corpus/table-tags/cold/read/page-001.png differ
corpus/table-tags/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="{9FE96B82-F974-0AAF-22DA-1F3836321E11}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:TagDef index="1" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="146.0700225830078" height="57.32315826416015"/><one:OEChildren><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="100.8300018310547"/></one:Columns><one:Row objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{47}{B0}" lastModifiedTime="2025-08-18T14:13:23.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{48}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Outer]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{56}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{59}{B0}" alignment="left"><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{60}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{61}{B0}" lastModifiedTime="2025-08-18T14:13:23.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{62}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner A]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{70}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{72F33822-D457-04E4-13E2-33AD4F4FD1C6}{80}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/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": "Edit a tagged table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/cold/read/page-002.png created
Binary files /dev/null and b/corpus/table-tags/cold/read/page-002.png differ
corpus/table-tags/cold/read/page-002.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="{E4B8C5D6-D05E-0F3B-1CF6-6FF98AB5D9CA}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:58:25.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Edit a tagged table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="97.23377227783203" height="80.63858032226562"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{46}{B0}"><one:Columns><one:Column index="0" width="51.99378204345703"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{48}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Now ]]></one:T></one:OE><one:OE author="Author" authorInitials="A" creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{4CAC08BB-A25D-0C3D-359D-9F88828043F3}{1}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Typed into]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{56}{B0}" lastModifiedBy="virtual" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{64}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z" lastModifiedBy="virtual"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[C]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[D]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{F03E7C7C-C235-076A-3FF2-B758F4285ECA}{82}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/table-tags/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/unsup-lab/table-tags-candidate",
3 "expected_pages": 3,
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/table-tags/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/table-tags/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/table-tags/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "a69d74aabd379aaa9db37c38daa4f2fe20ae1fecc7a35d11415e5068b893f9b1",
6 "mtime_ns": 1790740524265850413
7 },
8 {
9 "path": "Tables.one",
10 "bytes": 31808,
11 "sha256": "6a2acc111e28655e60b76266e8fc76f7bb5d7adcf576863a574d64412e3400cd",
12 "mtime_ns": 1790740524265655662
13 }
14]
corpus/table-tags/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/table-tags/native/commands.jsonl created+4
...@@ -0,0 +1,4 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2203CF5A", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2203CF5A -ExpectedPages 4 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 4 pages.\r\n", "stderr": "", "error": null}
4{"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/table-tags/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-2203cf5a", "hostname": "ONE-M6-2203CF5A"}
corpus/table-tags/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/table-tags/native/notebook/Open Notebook.onetoc2 differ
corpus/table-tags/native/notebook/Tables.one created
Binary files /dev/null and b/corpus/table-tags/native/notebook/Tables.one differ
corpus/table-tags/native/notebook/fixture.json created+19
...@@ -0,0 +1,19 @@
1{
2 "sections": [
3 "Tables.one"
4 ],
5 "pages": [
6 {
7 "section": "Tables.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:TagDef index=\"0\" type=\"0\" symbol=\"3\" fontColor=\"automatic\" highlightColor=\"none\" name=\"To Do\"/><one:TagDef index=\"1\" type=\"1\" symbol=\"13\" fontColor=\"automatic\" highlightColor=\"none\" name=\"Important\"/><one:Title><one:OE><one:T><![CDATA[Check a table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"480\" height=\"200\"/><one:OEChildren><one:OE><one:T><![CDATA[Before]]></one:T></one:OE><one:OE><one:Tag index=\"0\" completed=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\"/><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"120\"/><one:Column index=\"1\" width=\"120\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Checked by a click]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Tables.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:TagDef index=\"0\" type=\"0\" symbol=\"3\" fontColor=\"automatic\" highlightColor=\"none\" name=\"To Do\"/><one:TagDef index=\"1\" type=\"1\" symbol=\"13\" fontColor=\"automatic\" highlightColor=\"none\" name=\"Important\"/><one:Title><one:OE><one:T><![CDATA[Edit a tagged table]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"480\" height=\"200\"/><one:OEChildren><one:OE><one:T><![CDATA[Before]]></one:T></one:OE><one:OE><one:Tag index=\"1\" completed=\"true\" creationDate=\"2020-01-02T03:04:05.000Z\"/><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"120\"/><one:Column index=\"1\" width=\"120\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Typed into]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[C]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[D]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
13 },
14 {
15 "section": "Tables.one",
16 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:TagDef index=\"0\" type=\"0\" symbol=\"3\" fontColor=\"automatic\" highlightColor=\"none\" name=\"To Do\"/><one:TagDef index=\"1\" type=\"1\" symbol=\"13\" fontColor=\"automatic\" highlightColor=\"none\" name=\"Important\"/><one:Title><one:OE><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"480\" height=\"200\"/><one:OEChildren><one:OE><one:T><![CDATA[Before]]></one:T></one:OE><one:OE><one:Tag index=\"1\" completed=\"true\" creationDate=\"2020-01-02T03:04:05.000Z\"/><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"120\"/><one:Column index=\"1\" width=\"120\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Outer]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:Tag index=\"0\" completed=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\"/><one:Table bordersVisible=\"true\"><one:Columns><one:Column index=\"0\" width=\"120\"/><one:Column index=\"1\" width=\"120\"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Inner A]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Inner B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
17 }
18 ]
19}
\ No newline at end of file
corpus/table-tags/native/notebook/synthetic.one created
Binary files /dev/null and b/corpus/table-tags/native/notebook/synthetic.one differ
corpus/table-tags/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-2203CF5A",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/table-tags/native/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="{1564CCB1-3A19-48CD-9F36-C4B72B1E3D9D}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T03:43:33.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{8FDA4B0B-74A2-0314-3F99-566B51D3888F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-30T03:43:32.000Z" color="#8AA8E4"><one:Page ID="{E0545639-76BF-00E4-1BC4-7621AF5DC106}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T03:43:32.000Z" pageLevel="1"/></one:Section><one:Section name="Tables" ID="{116958F2-3F72-057E-1C7B-0840F7C0A7F0}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tables.one" lastModifiedTime="2026-09-30T03:43:33.000Z" color="#91BAAE"><one:Page ID="{B6CA383B-AB1D-0817-2C81-C82DCC53DA15}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-09-30T03:43:32.000Z" pageLevel="1"/><one:Page ID="{16F9DC93-FDC6-0145-0946-0221ADDC134A}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:43:32.000Z" pageLevel="1"/><one:Page ID="{6DA872C7-D4EC-04D1-376A-72E0115BD491}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:43:33.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/table-tags/native/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": "Edit a tagged table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/native/read/page-000.png created
Binary files /dev/null and b/corpus/table-tags/native/read/page-000.png differ
corpus/table-tags/native/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="{16F9DC93-FDC6-0145-0946-0221ADDC134A}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:43:32.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Edit a tagged table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="97.23377227783203" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{46}{B0}"><one:Columns><one:Column index="0" width="51.99378204345703"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Typed into]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{64}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[C]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[D]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{027F6539-EFAD-0914-2A42-DA80D341944A}{82}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/native/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": "Delete and undo - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/native/read/page-001.png created
Binary files /dev/null and b/corpus/table-tags/native/read/page-001.png differ
corpus/table-tags/native/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="{6DA872C7-D4EC-04D1-376A-72E0115BD491}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:43:33.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:TagDef index="1" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="146.0700225830078" height="57.32315826416015"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="100.8300018310547"/></one:Columns><one:Row objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Outer]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{59}{B0}" alignment="left"><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{60}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{61}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{62}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner A]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{70}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{80B22167-F9CF-0A9A-0652-5E7568261B46}{80}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/native/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": "Check a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/native/read/page-002.png created
Binary files /dev/null and b/corpus/table-tags/native/read/page-002.png differ
corpus/table-tags/native/read/page-002.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="{B6CA383B-AB1D-0817-2C81-C82DCC53DA15}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-09-30T03:43:32.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.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-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Check a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="130.4485626220703" height="48.80315780639648"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{29}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{30}{B0}"><one:Columns><one:Column index="0" width="85.20857238769531"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{31}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{32}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Checked by a click]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{40}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{2F784E60-03BF-09AF-1D65-9EC7FE1D3594}{49}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/native/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": "A notebook created in Rust. - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-tags/native/read/page-003.png created
Binary files /dev/null and b/corpus/table-tags/native/read/page-003.png differ
corpus/table-tags/native/read/page-003.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="{E0545639-76BF-00E4-1BC4-7621AF5DC106}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-30T03:43:32.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{8FDA4B0B-74A2-0314-3F99-566B51D3888F}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{8FDA4B0B-74A2-0314-3F99-566B51D3888F}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-tags/native/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/table-tags/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/unsup-lab/table-tags-input/notebook",
3 "expected_pages": 4,
4 "author": "tools/native/pages.ps1",
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 "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/table-tags/native/scripts/author.ps1 created+42
...@@ -0,0 +1,42 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
6$inputs = Join-Path $Root 'inputs'
7New-Item -ItemType Directory -Path $inputs | Out-Null
8$app = New-Object -ComObject OneNote.Application
9$notebookId = ''
10try {
11 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
12 $sections = @{}
13 foreach ($path in $fixture.sections) {
14 $parts = $path.Split('/')
15 if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
16 $parent = $notebookId
17 for ($index = 0; $index -lt $parts.Count; $index++) {
18 $id = ''
19 $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
20 $parent = $id
21 }
22 $sections[$path] = $id
23 }
24 $index = 0
25 foreach ($item in $fixture.pages) {
26 $pageId = ''
27 $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
28 [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
29 $xml.DocumentElement.SetAttribute('ID', $pageId)
30 [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
31 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
32 $index++
33 }
34 $app.SyncHierarchy($notebookId)
35 $app.CloseNotebook($notebookId, $false)
36 $notebookId = ''
37} finally {
38 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
39 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
40 [GC]::Collect()
41 [GC]::WaitForPendingFinalizers()
42}
corpus/table-tags/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/table-tags/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/table-tags/native/source.json created+20
...@@ -0,0 +1,20 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
6 "mtime_ns": 1790739021434250039
7 },
8 {
9 "path": "fixture.json",
10 "bytes": 4203,
11 "sha256": "5fef6d7a199e9f4ac5ffa07adc829a24bb9127b0d5e952c5129d7b3346a84c52",
12 "mtime_ns": 1790739021485254100
13 },
14 {
15 "path": "synthetic.one",
16 "bytes": 3348,
17 "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
18 "mtime_ns": 1790739021434686911
19 }
20]
corpus/table-tags/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/canvas/Cargo.toml+1-1
...@@ -20,7 +20,7 @@ skrifa = "0.44.0"...@@ -20,7 +20,7 @@ skrifa = "0.44.0"
2020
21accesskit = { version = "0.25.0", optional = true }21accesskit = { version = "0.25.0", optional = true }
22color_quant = { version = "1.1", optional = true }22color_quant = { version = "1.1", optional = true }
23image = { version = "0.25", default-features = false, features = ["jpeg"], optional = true }23image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true }
24png = { version = "0.18", optional = true }24png = { version = "0.18", optional = true }
25resvg = { version = "0.48.1", default-features = false, optional = true }25resvg = { version = "0.48.1", default-features = false, optional = true }
26sha2 = { version = "0.11", optional = true }26sha2 = { version = "0.11", optional = true }
crates/canvas/examples/unsupported_inventory.rs created+659
...@@ -0,0 +1,659 @@
1//! Every place the canvas shows a page's content as a placeholder, draws it read-only, or
2//! refuses an ordinary edit as content it cannot edit, across the active pages of every
3//! section under the given directories, grouped by kind as Markdown. Identical sections
4//! count once.
5//!
6//! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus`
7
8use canvas::{
9 document::TextPosition,
10 editor::{CanvasEditor, EditorError, TextOutline},
11 layout::{LayoutError, TextEngine},
12 outline::Arrange,
13};
14use onestore::{
15 Arena, ExGuid, Section,
16 page::{
17 Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
18 text::{Affinity, EditError},
19 },
20};
21use std::{
22 collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher},
23 hash::{Hash, Hasher},
24 path::{Path, PathBuf},
25};
26
27#[derive(Default)]
28struct Inventory {
29 /// Kind to the places it occurs, as `section — page`.
30 kinds: BTreeMap<(Stage, String), Vec<String>>,
31 sections: usize,
32 pages: usize,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
36enum Stage {
37 /// Drawn as an "Unsupported content" (or "Image unavailable") box.
38 Placeholder,
39 /// Drawn as stored, but the editor cannot hold it.
40 ReadOnly,
41 /// An ordinary edit the editor refuses as content it cannot edit.
42 Refused,
43 /// The page or section does not open at all.
44 Failure,
45}
46
47impl Inventory {
48 fn add(&mut self, stage: Stage, kind: impl Into<String>, place: &str) {
49 self.kinds
50 .entry((stage, kind.into()))
51 .or_default()
52 .push(place.to_owned());
53 }
54}
55
56/// MS-ONE's names for the object kinds it documents, and the observed undocumented ones.
57fn jcid_name(jcid: u32) -> String {
58 let name = match jcid {
59 0x60007 => "section",
60 0x60008 => "page series",
61 0x6000b => "page",
62 0x6000c => "outline",
63 0x6000d => "outline element",
64 0x6000e => "rich text",
65 0x60011 => "picture",
66 0x60012 => "number list",
67 0x60014 => "ink container",
68 0x60019 => "outline group",
69 0x60022 => "table",
70 0x60023 => "table row",
71 0x60024 => "table cell",
72 0x6002c => "title",
73 0x60035 => "embedded file",
74 0x60037 => "page manifest",
75 0x6003c => "version history",
76 0x6003d => "version proxy",
77 _ => "undocumented",
78 };
79 format!("jcid 0x{jcid:05x} ({name})")
80}
81
82fn unsupported_error(error: &EditorError) -> bool {
83 matches!(
84 error,
85 EditorError::Layout(LayoutError::UnsupportedContent)
86 | EditorError::Edit(EditError::UnsupportedContent)
87 )
88}
89
90/// What a text paragraph holds that bears on editing it.
91fn traits(text: &Paragraph) -> Vec<&'static str> {
92 let spans = text.spans();
93 let mut traits = Vec::new();
94 if text.text().contains('\u{fffc}')
95 || spans.iter().any(|s| s.format.embedded_object == Some(true))
96 {
97 traits.push("embedded object in text");
98 }
99 if spans.iter().any(|s| s.format.math == Some(true)) {
100 traits.push("equation");
101 }
102 if spans.iter().any(|s| s.format.hyperlink == Some(true)) {
103 traits.push("link");
104 }
105 if spans.iter().any(|s| s.format.hidden == Some(true)) {
106 traits.push("hidden text");
107 }
108 if text.text().is_empty() {
109 traits.push("empty");
110 }
111 traits
112}
113
114/// Why `node`, alone in an outline, does not lay out as content the canvas understands.
115fn culprit(
116 engine: &mut TextEngine,
117 outline: &Outline,
118 node: &PageParagraph,
119 page: &Page,
120) -> Option<String> {
121 let alone = Outline {
122 paragraphs: vec![PageParagraph {
123 parent: None,
124 level: 1,
125 ..node.clone()
126 }],
127 unsupported: Vec::new(),
128 ..outline.clone()
129 };
130 match alone.layout(engine, &page.definitions) {
131 Err(LayoutError::UnsupportedContent) => {}
132 _ => return None,
133 }
134 Some(match &node.content {
135 ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)),
136 ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(),
137 ParagraphContent::Table(table) => {
138 let cells = || table.rows.iter().flat_map(|row| &row.cells);
139 if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) {
140 format!(
141 "table cell holding {}",
142 cell.unsupported
143 .iter()
144 .map(|u| jcid_name(u.jcid))
145 .collect::<Vec<_>>()
146 .join(", ")
147 )
148 } else {
149 let inner = cells()
150 .flat_map(|cell| &cell.paragraphs)
151 .find_map(|inner| culprit(engine, outline, inner, page));
152 format!(
153 "table cell: {}",
154 inner.unwrap_or_else(|| "nested content".into())
155 )
156 }
157 }
158 ParagraphContent::Image(_) => "picture without a size".into(),
159 ParagraphContent::Ink(_) => "handwriting without strokes".into(),
160 ParagraphContent::Attachment(_) => "file".into(),
161 ParagraphContent::Text(text) => {
162 let lists: Vec<String> = node
163 .lists
164 .iter()
165 .filter_map(|id| page.definitions.get(id))
166 .map(|definition| format!("{:?}", definition.kind))
167 .collect();
168 if !lists.is_empty() {
169 format!("list marker {}", lists.join(", "))
170 } else if text
171 .text
172 .spans()
173 .iter()
174 .any(|s| s.format.math == Some(true))
175 {
176 "equation the math layout does not parse".into()
177 } else {
178 format!("text ({})", traits(&text.text).join(", "))
179 }
180 }
181 })
182}
183
184/// Why an outline the page shows is not editable.
185fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec<String> {
186 let mut reasons: Vec<String> = outline
187 .unsupported
188 .iter()
189 .map(|u| format!("outline child {}", jcid_name(u.jcid)))
190 .collect();
191 reasons.extend(
192 outline
193 .paragraphs
194 .iter()
195 .filter_map(|node| culprit(engine, outline, node, page)),
196 );
197 if reasons.is_empty() {
198 let text = outline.paragraphs.iter().any(|node| {
199 node.text().is_some()
200 || matches!(&node.content, ParagraphContent::Table(t)
201 if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty()))
202 });
203 reasons.push(if text {
204 match TextOutline::from_outline(engine, outline, &page.definitions) {
205 Err(error) => format!("editor: {error}"),
206 Ok(_) => "outline on its own is editable".into(),
207 }
208 } else {
209 let kinds: BTreeSet<&str> = outline
210 .paragraphs
211 .iter()
212 .map(|node| match node.content {
213 ParagraphContent::Image(_) => "pictures",
214 ParagraphContent::Attachment(_) => "files",
215 ParagraphContent::Ink(_) => "handwriting",
216 _ => "other",
217 })
218 .collect();
219 format!(
220 "no paragraph of text (only {})",
221 kinds.into_iter().collect::<Vec<_>>().join(", ")
222 )
223 });
224 }
225 reasons
226}
227
228/// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data
229/// the renderer does not decode, named by its leading bytes.
230fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) {
231 let Some(bytes) = &image.bytes else {
232 return inventory.add(
233 Stage::Placeholder,
234 format!("{at} picture without data"),
235 place,
236 );
237 };
238 #[cfg(feature = "gpu")]
239 if [Some(bytes), image.display.as_ref()]
240 .into_iter()
241 .flatten()
242 .all(|bytes| draw::RasterImage::measure(bytes).is_err())
243 {
244 let format = match &bytes[..bytes.len().min(4)] {
245 [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)",
246 [1, 0, 0, 0] => "EMF",
247 [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF",
248 [b'B', b'M', ..] => "BMP",
249 _ => "an unknown format",
250 };
251 inventory.add(
252 Stage::Placeholder,
253 format!("{at} picture stored as {format}"),
254 place,
255 );
256 }
257 #[cfg(not(feature = "gpu"))]
258 let _ = bytes;
259}
260
261/// Pictures that draw as "Image unavailable" and objects the model keeps aside.
262fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) {
263 for node in nodes {
264 match &node.content {
265 ParagraphContent::Unsupported(u) => inventory.add(
266 Stage::Placeholder,
267 format!("{at} paragraph holding {}", jcid_name(u.jcid)),
268 place,
269 ),
270 ParagraphContent::Image(image) => picture(image, at, inventory, place),
271 ParagraphContent::Table(table) => {
272 for cell in table.rows.iter().flat_map(|row| &row.cells) {
273 for u in &cell.unsupported {
274 inventory.add(
275 Stage::Placeholder,
276 format!("table cell child {}", jcid_name(u.jcid)),
277 place,
278 );
279 }
280 scan_paragraphs(&cell.paragraphs, "table cell", inventory, place);
281 }
282 }
283 _ => {}
284 }
285 }
286}
287
288/// Ordinary edits at every text paragraph of every editable outline: typing at its start,
289/// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its
290/// middle into the next.
291fn probe(
292 editor: &mut CanvasEditor,
293 engine: &mut TextEngine,
294 inventory: &mut Inventory,
295 place: &str,
296) {
297 let ids: Vec<ExGuid> = editor.outlines().iter().map(|o| o.id).collect();
298 for id in ids {
299 if editor.focus_outline(id).is_err() {
300 continue;
301 }
302 let document = editor.active_outline().document();
303 let containers = containers(document.nodes());
304 let leaves: Vec<(Option<ExGuid>, Paragraph)> = document
305 .text_nodes()
306 .map(|node| {
307 (
308 containers.get(&node.id).copied().flatten(),
309 node.text().unwrap().text.clone(),
310 )
311 })
312 .collect();
313 for (index, (container, text)) in leaves.iter().enumerate() {
314 let length = text.text().encode_utf16().count() as u32;
315 // The middle of what shows, where a click could put the caret.
316 let Ok(projection) = text.project() else {
317 continue;
318 };
319 let shown = projection.text();
320 let middle = shown
321 .text()
322 .char_indices()
323 .map(|(at, _)| at)
324 .nth(shown.text().chars().count() / 2)
325 .and_then(|at| shown.utf16_offset(at).ok())
326 .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok())
327 .unwrap_or(length);
328 let at = |offset| TextPosition {
329 paragraph: index,
330 offset,
331 };
332 let next = leaves.get(index + 1);
333 let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![
334 ("typing at a paragraph's start", [at(0); 2], Probe::Type),
335 ("typing inside a paragraph", [at(middle); 2], Probe::Type),
336 ("typing at a paragraph's end", [at(length); 2], Probe::Type),
337 ("Enter inside a paragraph", [at(middle); 2], Probe::Enter),
338 (
339 "Delete at a paragraph's end",
340 [at(length); 2],
341 Probe::Delete,
342 ),
343 ];
344 if index > 0 {
345 probes.push((
346 "Backspace at a paragraph's start",
347 [at(0); 2],
348 Probe::Backspace,
349 ));
350 }
351 if let Some((_, next_text)) = next {
352 let end = next_text.text().encode_utf16().count() as u32 / 2;
353 probes.push((
354 "deleting a selection into the next paragraph",
355 [
356 at(middle),
357 TextPosition {
358 paragraph: index + 1,
359 offset: end,
360 },
361 ],
362 Probe::Backspace,
363 ));
364 }
365 for (name, positions, kind) in probes {
366 if editor.select(positions.into()).is_err() {
367 continue;
368 }
369 let result = match kind {
370 Probe::Type => editor.insert(engine, "x").map(|()| true),
371 Probe::Enter => editor.enter(engine, false).map(|()| true),
372 Probe::Delete => editor.delete(engine, false),
373 Probe::Backspace => editor.delete(engine, true),
374 };
375 match result {
376 Ok(true) => {
377 editor.undo(engine).ok();
378 }
379 Ok(false) => {}
380 Err(error) if unsupported_error(&error) => {
381 let crossing = positions[0].paragraph != positions[1].paragraph
382 || name.starts_with("Backspace")
383 || name.starts_with("Delete");
384 let neighbour = match name {
385 n if n.starts_with("Backspace") => leaves.get(index - 1),
386 _ if crossing => next,
387 _ => None,
388 };
389 let mut why: Vec<&str> = traits(text);
390 if let Some((other, other_text)) = neighbour {
391 if other != container {
392 why.push("across a table cell boundary");
393 }
394 why.extend(traits(other_text).into_iter().map(|t| match t {
395 "embedded object in text" => "next to an embedded object",
396 "equation" => "next to an equation",
397 "link" => "next to a link",
398 t => t,
399 }));
400 }
401 if container.is_some() {
402 why.push("in a table cell");
403 }
404 why.sort();
405 why.dedup();
406 inventory.add(
407 Stage::Refused,
408 format!("{name} [{}]", why.join(", ")),
409 place,
410 );
411 }
412 Err(_) => {}
413 }
414 }
415 }
416 }
417}
418
419#[derive(Clone, Copy)]
420enum Probe {
421 Type,
422 Enter,
423 Delete,
424 Backspace,
425}
426
427/// Each paragraph's table cell, `None` at the outline's top.
428fn containers(nodes: &[PageParagraph]) -> BTreeMap<ExGuid, Option<ExGuid>> {
429 let mut map = BTreeMap::new();
430 let mut pending = vec![(None, nodes)];
431 while let Some((container, nodes)) = pending.pop() {
432 for node in nodes {
433 map.insert(node.id, container);
434 if let ParagraphContent::Table(table) = &node.content {
435 for cell in table.rows.iter().flat_map(|row| &row.cells) {
436 pending.push((Some(cell.id), cell.paragraphs.as_slice()));
437 }
438 }
439 }
440 }
441 map
442}
443
444fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) {
445 for object in &page.objects {
446 match object {
447 PageObject::Unsupported(u) => inventory.add(
448 Stage::Placeholder,
449 format!("page object {}", jcid_name(u.jcid)),
450 place,
451 ),
452 PageObject::Image(image) => picture(image, "page", inventory, place),
453 PageObject::Outline(outline) => {
454 for u in &outline.unsupported {
455 inventory.add(
456 Stage::Placeholder,
457 format!("outline child {}", jcid_name(u.jcid)),
458 place,
459 );
460 }
461 scan_paragraphs(&outline.paragraphs, "outline", inventory, place);
462 }
463 PageObject::Title(title) => {
464 for outline in &title.outlines {
465 scan_paragraphs(&outline.paragraphs, "title", inventory, place);
466 }
467 }
468 _ => {}
469 }
470 }
471 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
472 Ok(editor) => editor,
473 Err(error) => {
474 inventory.add(
475 Stage::Failure,
476 format!("page does not open: {error}"),
477 place,
478 );
479 return;
480 }
481 };
482 for object in &page.objects {
483 let (outlines, title) = match object {
484 PageObject::Outline(outline) => (std::slice::from_ref(outline), None),
485 PageObject::Title(title) => (title.outlines.as_slice(), Some(title)),
486 _ => continue,
487 };
488 for outline in outlines {
489 if editor.has_page_outline(outline.id)
490 || editor.outlines().iter().any(|o| o.id == outline.id)
491 {
492 continue;
493 }
494 if let Some(title) = title {
495 if title.date == Some(outline.id) {
496 if editor.date().is_none() {
497 inventory.add(Stage::ReadOnly, "page date the date editor declines", place);
498 }
499 continue;
500 }
501 if !outline.title {
502 continue;
503 }
504 }
505 let placeholder = matches!(
506 outline.layout(engine, &page.definitions),
507 Err(LayoutError::UnsupportedContent)
508 );
509 let at = if title.is_some() { "title" } else { "outline" };
510 for reason in diagnose(engine, outline, &page) {
511 let stage = if placeholder {
512 Stage::Placeholder
513 } else {
514 Stage::ReadOnly
515 };
516 let whole = if placeholder {
517 format!("whole {at} as a box: {reason}")
518 } else {
519 format!("{at} read-only: {reason}")
520 };
521 inventory.add(stage, whole, place);
522 }
523 }
524 }
525 probe(&mut editor, engine, inventory, place);
526}
527
528fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) {
529 let Ok(image) = std::fs::read(path) else {
530 return;
531 };
532 let name = path
533 .strip_prefix(root)
534 .unwrap_or(path)
535 .display()
536 .to_string();
537 let arena = Arena::default();
538 let mut section = match Section::open(&arena, image) {
539 Ok(section) => section,
540 Err(error) => {
541 inventory.add(
542 Stage::Failure,
543 format!("section does not open: {}", error.message),
544 &name,
545 );
546 return;
547 }
548 };
549 inventory.sections += 1;
550 let pages = match section.pages() {
551 Ok(pages) => pages,
552 Err(error) => {
553 inventory.add(
554 Stage::Failure,
555 format!("page list does not read: {}", error.message),
556 &name,
557 );
558 return;
559 }
560 };
561 for (space, title, _) in pages {
562 inventory.pages += 1;
563 let place = format!("{name} — {title}");
564 match section.page(space) {
565 Ok(model) => page(engine, model, &place, inventory),
566 Err(error) => inventory.add(
567 Stage::Failure,
568 format!("page does not read: {}", error.message),
569 &place,
570 ),
571 }
572 }
573}
574
575fn sections(directory: &Path, out: &mut Vec<PathBuf>) {
576 let Ok(entries) = std::fs::read_dir(directory) else {
577 return;
578 };
579 let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
580 entries.sort();
581 for path in entries {
582 if path.is_dir() {
583 sections(&path, out);
584 } else if path.extension().is_some_and(|e| e == "one") {
585 out.push(path);
586 }
587 }
588}
589
590fn main() {
591 let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
592 let mut engine = TextEngine::default();
593 let mut inventory = Inventory::default();
594 let mut seen = BTreeSet::new();
595 let mut duplicates = 0;
596 for root in &roots {
597 let mut paths = Vec::new();
598 sections(root, &mut paths);
599 for path in paths {
600 let Ok(bytes) = std::fs::read(&path) else {
601 continue;
602 };
603 let mut hasher = DefaultHasher::new();
604 bytes.hash(&mut hasher);
605 if !seen.insert(hasher.finish()) {
606 duplicates += 1;
607 continue;
608 }
609 section(&mut engine, &path, root, &mut inventory);
610 }
611 }
612 println!("# Unsupported content inventory\n");
613 println!(
614 "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n",
615 inventory.sections,
616 inventory.pages,
617 roots
618 .iter()
619 .map(|r| format!("`{}`", r.display()))
620 .collect::<Vec<_>>()
621 .join(", ")
622 );
623 let headings = [
624 (Stage::Placeholder, "Drawn as a placeholder box"),
625 (Stage::ReadOnly, "Drawn as stored, read-only"),
626 (
627 Stage::Refused,
628 "Edits refused as content the editor cannot edit",
629 ),
630 (Stage::Failure, "Does not open"),
631 ];
632 for (stage, heading) in headings {
633 let mut rows: Vec<(&String, &Vec<String>)> = inventory
634 .kinds
635 .iter()
636 .filter(|((s, _), _)| *s == stage)
637 .map(|((_, kind), places)| (kind, places))
638 .collect();
639 rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
640 println!("## {heading}\n");
641 if rows.is_empty() {
642 println!("None.\n");
643 continue;
644 }
645 println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |");
646 for (kind, places) in rows {
647 let pages: BTreeSet<&String> = places.iter().collect();
648 let examples: Vec<String> = pages.iter().take(3).map(|p| format!("`{p}`")).collect();
649 println!(
650 "| {} | {} | {} | {} |",
651 kind.replace('|', "\\|"),
652 places.len(),
653 pages.len(),
654 examples.join("<br>").replace('|', "\\|")
655 );
656 }
657 println!();
658 }
659}
crates/canvas/src/document.rs+228-14
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1use onestore::page::text::{EditError, Paragraph, new_id};1use onestore::page::text::{EditError, Paragraph, new_id};
2use onestore::page::{PageParagraph, ParagraphContent, TextObject};2use onestore::page::{PageParagraph, ParagraphContent, TableCell, TextObject};
3use onestore::{ExGuid, document::Format};3use onestore::{ExGuid, document::Format};
4use std::{4use std::{
5 collections::{BTreeMap, BTreeSet},5 collections::{BTreeMap, BTreeSet},
...@@ -247,10 +247,110 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {...@@ -247,10 +247,110 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {
247 Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}'))247 Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}'))
248}248}
249249
250/// What deleting `range`, which crosses a table's edge, leaves of `nodes`, whose
251/// first text leaf is paragraph `*next`, and whether all of them lay inside it. As OneNote 2010
252/// deletes such a selection, each container keeps what lies outside it and nothing joins
253/// across a container's edge: the end paragraphs keep their outer text, a cell inside keeps
254/// one empty paragraph, except that where the selection runs on past a table its rows with
255/// every cell inside go, and the table when all of them do. A paragraph that goes leaves its
256/// children to its parent.
257fn cut(
258 nodes: &[PageParagraph],
259 next: &mut usize,
260 range: &Range<TextPosition>,
261) -> Result<(Vec<PageParagraph>, bool), EditError> {
262 let (start, end) = (range.start, range.end);
263 let mut kept = Vec::new();
264 let mut gone = BTreeMap::new();
265 for node in nodes {
266 let inside = match &node.content {
267 ParagraphContent::Text(text) => {
268 let at = *next;
269 *next += 1;
270 let text = &text.text;
271 let length = text.utf16_offset(text.text().len())?;
272 let shown = match at {
273 at if at == start.paragraph => Some(text.slice(0..start.offset)?),
274 at if at == end.paragraph => Some(text.slice(end.offset..length)?),
275 _ => None,
276 };
277 if let Some(shown) = shown {
278 let mut node = node.clone();
279 node.text_mut().unwrap().text = shown;
280 kept.push(node);
281 continue;
282 }
283 start.paragraph < at && at < end.paragraph
284 }
285 ParagraphContent::Table(table) => {
286 let mut table = table.clone();
287 let mut whole_rows = Vec::new();
288 for row in &mut table.rows {
289 let mut cells = Vec::new();
290 for cell in &row.cells {
291 let (paragraphs, inside) = cut(&cell.paragraphs, next, range)?;
292 cells.push((cell, paragraphs, inside));
293 }
294 let whole = cells.iter().all(|(_, _, inside)| *inside);
295 row.cells = cells
296 .into_iter()
297 .map(|(cell, paragraphs, inside)| {
298 let paragraphs = if inside {
299 let (_, _, leaf) = leaves(&cell.paragraphs, None)
300 .next()
301 .ok_or(EditError::InvalidStructure)?;
302 let mut leaf = leaf.clone();
303 let text = &mut leaf.text_mut().unwrap().text;
304 *text = text.slice(0..0)?;
305 leaf.parent = None;
306 leaf.level = 1;
307 vec![leaf]
308 } else {
309 paragraphs
310 };
311 Ok(TableCell {
312 paragraphs,
313 ..cell.clone()
314 })
315 })
316 .collect::<Result<_, EditError>>()?;
317 whole_rows.push(whole);
318 }
319 // Rows go only where the selection runs on past the table.
320 if end.paragraph >= *next {
321 let mut whole = whole_rows.into_iter();
322 table.rows.retain(|_| !whole.next().unwrap_or(false));
323 }
324 if table.rows.is_empty() {
325 true
326 } else {
327 let mut node = node.clone();
328 node.content = ParagraphContent::Table(table);
329 kept.push(node);
330 continue;
331 }
332 }
333 _ => start.paragraph < *next && *next <= end.paragraph,
334 };
335 if inside {
336 gone.insert(node.id, node.parent);
337 } else {
338 kept.push(node.clone());
339 }
340 }
341 let whole = kept.is_empty();
342 for node in &mut kept {
343 while let Some(parent) = node.parent.and_then(|parent| gone.get(&parent)) {
344 node.parent = *parent;
345 }
346 }
347 Ok((kept, whole))
348}
349
250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets350/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
251/// an embedded object at the seam or carries one to another paragraph, or joins inside an351/// an embedded object at the seam or carries one to another paragraph, or joins inside an
252/// equation: the object's run data belongs to its paragraph, and an equation divides only at352/// equation's objects: the object's run data belongs to its paragraph, and an equation divides
253/// its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).353/// only at its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).
254fn moves_object(354fn moves_object(
255 first: &Paragraph,355 first: &Paragraph,
256 start: u32,356 start: u32,
...@@ -266,9 +366,21 @@ fn moves_object(...@@ -266,9 +366,21 @@ fn moves_object(
266 .chars()366 .chars()
267 .next()367 .next()
268 .map(|c| end + c.len_utf16() as u32);368 .map(|c| end + c.len_utf16() as u32);
369 // An equation joins where both seams lie outside its objects (fractions, scripts and the
370 // like), as OneNote 2010 joins two equations a deletion meets.
371 let nested = |text: &str| {
372 text.chars().fold(0_i32, |depth, c| match c {
373 '\u{fdd0}' => depth + 1,
374 '\u{fdef}' => depth - 1,
375 _ => depth,
376 }) > 0
377 };
269 Ok(first.text()[..before].ends_with('\u{fffc}')378 Ok(first.text()[..before].ends_with('\u{fffc}')
270 || last.text()[after..].contains('\u{fffc}')379 || last.text()[after..].contains('\u{fffc}')
271 || start > 0 && math(first, start) && following.is_some_and(|next| math(last, next)))380 || start > 0
381 && math(first, start)
382 && following.is_some_and(|next| math(last, next))
383 && (nested(&first.text()[..before]) || nested(&last.text()[..after])))
272}384}
273385
274/// Paragraph positions of each node's first text leaf, counting from `first`.386/// Paragraph positions of each node's first text leaf, counting from `first`.
...@@ -689,15 +801,15 @@ impl TextDocument {...@@ -689,15 +801,15 @@ impl TextDocument {
689 if range.start > range.end {801 if range.start > range.end {
690 return Err(EditError::InvalidRange);802 return Err(EditError::InvalidRange);
691 }803 }
804 if self.crosses_table(range.clone())? {
805 return self.replace_across(range, replacement);
806 }
692 let (container, start, first) = self807 let (container, start, first) = self
693 .leaf(range.start.paragraph)808 .leaf(range.start.paragraph)
694 .ok_or(EditError::InvalidRange)?;809 .ok_or(EditError::InvalidRange)?;
695 let (end_container, end, last) = self810 let (_, end, last) = self
696 .leaf(range.end.paragraph)811 .leaf(range.end.paragraph)
697 .ok_or(EditError::InvalidRange)?;812 .ok_or(EditError::InvalidRange)?;
698 if container != end_container {
699 return Err(EditError::UnsupportedContent);
700 }
701 let nodes = self.container(container)?;813 let nodes = self.container(container)?;
702 let first_text = &first.text().unwrap().text;814 let first_text = &first.text().unwrap().text;
703 let last_text = &last.text().unwrap().text;815 let last_text = &last.text().unwrap().text;
...@@ -763,6 +875,66 @@ impl TextDocument {...@@ -763,6 +875,66 @@ impl TextDocument {
763 })875 })
764 }876 }
765877
878 /// Whether `range` crosses a table's edge: its ends lie in different containers, or a table
879 /// lies between them.
880 pub(crate) fn crosses_table(&self, range: Range<TextPosition>) -> Result<bool, EditError> {
881 let (container, start, _) = self
882 .leaf(range.start.paragraph)
883 .ok_or(EditError::InvalidRange)?;
884 let (end_container, end, _) = self
885 .leaf(range.end.paragraph)
886 .ok_or(EditError::InvalidRange)?;
887 Ok(container != end_container
888 || self.container(container)?[start..end]
889 .iter()
890 .any(|node| matches!(node.content, ParagraphContent::Table(_))))
891 }
892
893 /// `replace` of a range crossing a table's edge, as OneNote 2010 deletes such a selection
894 /// before inserting at its start: see [`cut`].
895 fn replace_across(
896 &self,
897 range: Range<TextPosition>,
898 replacement: Vec<Paragraph>,
899 ) -> Result<DocumentEdit, EditError> {
900 // A caret never stands inside hidden text, such as a link's field code.
901 for end in [range.start, range.end] {
902 let text = self
903 .paragraph(end.paragraph)
904 .ok_or(EditError::InvalidRange)?;
905 let at = text.byte_offset(end.offset)?;
906 let hidden = |byte: usize| {
907 text.spans()
908 .iter()
909 .find(|span| byte < span.end)
910 .is_some_and(|span| span.format.hidden == Some(true))
911 };
912 if at > 0 && hidden(at - 1) && hidden(at) {
913 return Err(EditError::InvalidRange);
914 }
915 }
916 let root = |paragraph: usize| self.starts.partition_point(|start| *start <= paragraph) - 1;
917 let (first, last) = (root(range.start.paragraph), root(range.end.paragraph));
918 let (kept, _) = cut(
919 &self.nodes[first..=last],
920 &mut self.starts[first].clone(),
921 &range,
922 )?;
923 let mut nodes = self.nodes[..first].to_vec();
924 nodes.extend(kept);
925 nodes.extend_from_slice(&self.nodes[last + 1..]);
926 let mut cut = Self::from_nodes(nodes)?;
927 let insertion = cut.replace(range.start..range.start, replacement)?;
928 cut.apply(insertion)?;
929 let end = cut.nodes.len() + last + 1 - self.nodes.len();
930 Ok(DocumentEdit {
931 columns: BTreeMap::new(),
932 container: None,
933 range: first..last + 1,
934 replacement: cut.nodes.drain(first..end).collect(),
935 })
936 }
937
766 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties938 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
767 /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree939 /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
768 /// lies between them in one container. The lower text keeps its look under `base`, the940 /// lies between them in one container. The lower text keeps its look under `base`, the
...@@ -1245,9 +1417,9 @@ mod tests {...@@ -1245,9 +1417,9 @@ mod tests {
1245 .paragraphs()1417 .paragraphs()
1246 .map(Paragraph::text)1418 .map(Paragraph::text)
1247 .collect::<Vec<_>>(),1419 .collect::<Vec<_>>(),
1248 ["after"]1420 ["", "after"]
1249 );1421 );
1250 assert_eq!(document.nodes().len(), 1);1422 assert_eq!(document.nodes().len(), 2);
1251 }1423 }
12521424
1253 #[test]1425 #[test]
...@@ -1259,9 +1431,17 @@ mod tests {...@@ -1259,9 +1431,17 @@ mod tests {
1259 document.replace(position(1, 2)..position(1, 3), text()),1431 document.replace(position(1, 2)..position(1, 3), text()),
1260 Err(EditError::InvalidRange)1432 Err(EditError::InvalidRange)
1261 );1433 );
1434 let across = document
1435 .replace(position(1, 1)..position(3, 1), text())
1436 .unwrap();
1437 let mut deleted = document.clone();
1438 deleted.apply(across).unwrap();
1262 assert_eq!(1439 assert_eq!(
1263 document.replace(position(1, 0)..position(3, 1), text()),1440 deleted
1264 Err(EditError::UnsupportedContent)1441 .paragraphs()
1442 .map(Paragraph::text)
1443 .collect::<Vec<_>>(),
1444 ["before", "aX", "ight", "after"],
1265 );1445 );
1266 let ParagraphContent::Table(table) = &document.nodes()[1].content else {1446 let ParagraphContent::Table(table) = &document.nodes()[1].content else {
1267 panic!()1447 panic!()
...@@ -1778,9 +1958,43 @@ mod tests {...@@ -1778,9 +1958,43 @@ mod tests {
1778 for replacement in &replacements {1958 for replacement in &replacements {
1779 let mut document = original.clone();1959 let mut document = original.clone();
1780 let planned = document.replace(start..end, replacement.clone());1960 let planned = document.replace(start..end, replacement.clone());
1781 if regions[start.paragraph] != regions[end.paragraph] {1961 if regions[start.paragraph..=end.paragraph]
1782 assert_eq!(planned, Err(EditError::UnsupportedContent));1962 .iter()
1963 .any(|region| *region != regions[start.paragraph])
1964 {
1965 // Across a container's edge nothing joins: the start keeps what
1966 // precedes it, followed by the replacement, and the end what
1967 // follows it.
1968 let planned = planned.unwrap();
1783 assert_eq!(document, original);1969 assert_eq!(document, original);
1970 let undo = document.apply(planned).unwrap();
1971 let texts: Vec<_> =
1972 document.paragraphs().map(Paragraph::text).collect();
1973 let before: Vec<_> =
1974 original.paragraphs().map(Paragraph::text).collect();
1975 assert_eq!(texts[..start.paragraph], before[..start.paragraph]);
1976 let text = |position: TextPosition| before[position.paragraph];
1977 let byte = |position: TextPosition| {
1978 original
1979 .paragraphs()
1980 .nth(position.paragraph)
1981 .unwrap()
1982 .byte_offset(position.offset)
1983 .unwrap()
1984 };
1985 let mut inserted: Vec<String> =
1986 replacement.iter().map(|p| p.text().to_owned()).collect();
1987 inserted[0].insert_str(0, &text(start)[..byte(start)]);
1988 let at = start.paragraph;
1989 assert_eq!(texts[at..at + inserted.len()], inserted);
1990 assert!(
1991 texts[at + inserted.len()..].contains(&&text(end)[byte(end)..])
1992 );
1993 let after = document.clone();
1994 let redo = document.apply(undo).unwrap();
1995 assert_eq!(document, original, "{start:?}..{end:?}");
1996 document.apply(redo).unwrap();
1997 assert_eq!(document, after);
1784 continue;1998 continue;
1785 }1999 }
1786 let planned = planned.unwrap();2000 let planned = planned.unwrap();
crates/canvas/src/editor.rs+184-11
...@@ -126,6 +126,10 @@ pub struct CanvasEditor {...@@ -126,6 +126,10 @@ pub struct CanvasEditor {
126 recording: Option<recording::Live>,126 recording: Option<recording::Live>,
127 /// Drawings being dragged and how far, shown there until dropped.127 /// Drawings being dragged and how far, shown there until dropped.
128 ink_drag: Option<(Vec<ExGuid>, [f32; 2])>,128 ink_drag: Option<(Vec<ExGuid>, [f32; 2])>,
129 /// The paragraph each outline holding only pictures or files shows after them, by outline,
130 /// and whether it is stored: the first op that reaches it stores it, and undoing back to it
131 /// empty and alone removes it again, as OneNote 2010's undo does.
132 provisional: BTreeMap<ExGuid, (PageParagraph, bool)>,
129}133}
130134
131/// Imported page state the editable content does not carry.135/// Imported page state the editable content does not carry.
...@@ -280,9 +284,10 @@ impl TextOutline {...@@ -280,9 +284,10 @@ impl TextOutline {
280 outline.layout(engine, definitions)?284 outline.layout(engine, definitions)?
281 };285 };
282 let document = TextDocument::from_nodes(outline.paragraphs.clone())?;286 let document = TextDocument::from_nodes(outline.paragraphs.clone())?;
283 // The caret needs a paragraph of text to stand in.287 // The caret needs a paragraph of text to stand in; a page gives an outline holding
288 // none a provisional one (`page::build`).
284 if document.text_nodes().next().is_none() {289 if document.text_nodes().next().is_none() {
285 return Err(EditError::UnsupportedContent.into());290 return Err(EditError::InvalidStructure.into());
286 }291 }
287 Ok(Self {292 Ok(Self {
288 id: outline.id,293 id: outline.id,
...@@ -532,6 +537,23 @@ impl TextOutline {...@@ -532,6 +537,23 @@ impl TextOutline {
532 Ok(rectangles)537 Ok(rectangles)
533 }538 }
534539
540 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
541 /// line's left, right and baseline.
542 pub fn underlines(&self, index: usize, range: Range<u32>) -> Result<Vec<[f32; 3]>, EditError> {
543 let paragraph = self.paragraph_layout(index)?;
544 let selection = ParagraphSelection::new(
545 paragraph.cursor(range.start, Affinity::Downstream)?,
546 paragraph.cursor(range.end, Affinity::Upstream)?,
547 );
548 let [x, y] = paragraph.origin;
549 Ok(paragraph
550 .text
551 .underlines(selection)
552 .into_iter()
553 .map(|[left, right, baseline]| [left + x, right + x, baseline + y])
554 .collect())
555 }
556
535 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the557 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
536 /// next shown one.558 /// next shown one.
537 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {559 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {
...@@ -889,6 +911,7 @@ impl CanvasEditor {...@@ -889,6 +911,7 @@ impl CanvasEditor {
889 typing: None,911 typing: None,
890 recording: None,912 recording: None,
891 ink_drag: None,913 ink_drag: None,
914 provisional: BTreeMap::new(),
892 })915 })
893 }916 }
894917
...@@ -899,6 +922,7 @@ impl CanvasEditor {...@@ -899,6 +922,7 @@ impl CanvasEditor {
899 mut outlines,922 mut outlines,
900 date,923 date,
901 areas,924 areas,
925 provisional,
902 } = page::build(&mut page, engine, true)?;926 } = page::build(&mut page, engine, true)?;
903 let needs_caret = outlines.is_empty();927 let needs_caret = outlines.is_empty();
904 if needs_caret {928 if needs_caret {
...@@ -926,6 +950,10 @@ impl CanvasEditor {...@@ -926,6 +950,10 @@ impl CanvasEditor {
926 };950 };
927 }951 }
928 editor.objects = objects;952 editor.objects = objects;
953 editor.provisional = provisional
954 .into_iter()
955 .map(|(outline, blank)| (outline, (blank, false)))
956 .collect();
929 editor.header = PageHeader {957 editor.header = PageHeader {
930 title: page.title,958 title: page.title,
931 identity: page.identity,959 identity: page.identity,
...@@ -1030,6 +1058,10 @@ impl CanvasEditor {...@@ -1030,6 +1058,10 @@ impl CanvasEditor {
1030 && let Some(kept) = own.remove(&outline.id)1058 && let Some(kept) = own.remove(&outline.id)
1031 {1059 {
1032 *outline = kept;1060 *outline = kept;
1061 match self.provisional.remove(&outline.id) {
1062 Some(provisional) => fresh.provisional.insert(outline.id, provisional),
1063 None => fresh.provisional.remove(&outline.id),
1064 };
1033 }1065 }
1034 }1066 }
1035 let position = fresh.outlines.iter().position(|outline| outline.id == id);1067 let position = fresh.outlines.iter().position(|outline| outline.id == id);
...@@ -1132,7 +1164,13 @@ impl CanvasEditor {...@@ -1132,7 +1164,13 @@ impl CanvasEditor {
1132 let mut outline = match content {1164 let mut outline = match content {
1133 page::Content::Editable(id) => {1165 page::Content::Editable(id) => {
1134 match self.outlines.iter().find(|outline| outline.id == *id) {1166 match self.outlines.iter().find(|outline| outline.id == *id) {
1135 Some(outline) => outline.snapshot(),1167 Some(outline) => {
1168 let mut outline = outline.snapshot();
1169 if let Some((blank, false)) = self.provisional.get(id) {
1170 outline.paragraphs.retain(|node| node.id != blank.id);
1171 }
1172 outline
1173 }
1136 None => continue,1174 None => continue,
1137 }1175 }
1138 }1176 }
...@@ -1469,6 +1507,7 @@ impl CanvasEditor {...@@ -1469,6 +1507,7 @@ impl CanvasEditor {
1469 typing: None,1507 typing: None,
1470 recording: None,1508 recording: None,
1471 ink_drag: None,1509 ink_drag: None,
1510 provisional: BTreeMap::new(),
1472 })1511 })
1473 }1512 }
14741513
...@@ -1647,6 +1686,16 @@ impl CanvasEditor {...@@ -1647,6 +1686,16 @@ impl CanvasEditor {
1647 }1686 }
16481687
1649 /// Provisional input geometry outside the document's outline collection.1688 /// Provisional input geometry outside the document's outline collection.
1689 /// The caret a run of typing left while typing there still joins the run: the word
1690 /// before it is still being written.
1691 pub fn typing(&self) -> Option<(ExGuid, TextPosition)> {
1692 let (outline, at, depth) = self.typing?;
1693 (outline == self.active_outline().id
1694 && self.selection().positions == [at; 2]
1695 && depth == self.undo.len())
1696 .then_some((outline, at))
1697 }
1698
1650 pub fn caret_outline(&self) -> Option<&TextOutline> {1699 pub fn caret_outline(&self) -> Option<&TextOutline> {
1651 match &self.active {1700 match &self.active {
1652 Focus::Caret { outline, .. } => Some(outline),1701 Focus::Caret { outline, .. } => Some(outline),
...@@ -2192,6 +2241,12 @@ impl CanvasEditor {...@@ -2192,6 +2241,12 @@ impl CanvasEditor {
2192 self.redo.clear();2241 self.redo.clear();
2193 return Ok(());2242 return Ok(());
2194 }2243 }
2244 let file = match self.across(engine, |editor, engine| {
2245 editor.insert_attachment(engine, file.clone())
2246 })? {
2247 Some(()) => return Ok(()),
2248 None => file,
2249 };
2195 let [anchor, focus] = self.selection().positions;2250 let [anchor, focus] = self.selection().positions;
2196 let (start, end) = (anchor.min(focus), anchor.max(focus));2251 let (start, end) = (anchor.min(focus), anchor.max(focus));
2197 let mut format = self.typing_format(start)?;2252 let mut format = self.typing_format(start)?;
...@@ -2261,7 +2316,19 @@ impl CanvasEditor {...@@ -2261,7 +2316,19 @@ impl CanvasEditor {
2261 })2316 })
2262 .ok_or(EditError::InvalidRange)?;2317 .ok_or(EditError::InvalidRange)?;
2263 self.finish_composition();2318 self.finish_composition();
2264 let image = self.remove_picture(index);2319 let mut image = self.remove_picture(index);
2320 // A printout page's XPS package cannot be written back, so undoing its deletion
2321 // restores the picture OneNote showed of it.
2322 if image.printout.is_some()
2323 && image
2324 .bytes
2325 .as_deref()
2326 .is_some_and(|bytes| bytes.starts_with(b"PK\x03\x04"))
2327 && let Some(shown) = image.display.take()
2328 {
2329 image.bytes = Some(shown);
2330 image.printout = None;
2331 }
2265 self.undo.push(History::Picture {2332 self.undo.push(History::Picture {
2266 index,2333 index,
2267 image: Some(Box::new(image)),2334 image: Some(Box::new(image)),
...@@ -2484,6 +2551,34 @@ impl CanvasEditor {...@@ -2484,6 +2551,34 @@ impl CanvasEditor {
2484 result2551 result
2485 }2552 }
24862553
2554 /// Runs `edit` once a selection crossing a table's edge is deleted, as OneNote 2010 deletes
2555 /// one before what replaces it goes in at its start, in one undo step; `None` for any
2556 /// other selection.
2557 fn across<T>(
2558 &mut self,
2559 engine: &mut TextEngine,
2560 edit: impl FnOnce(&mut Self, &mut TextEngine) -> Result<T, EditorError>,
2561 ) -> Result<Option<T>, EditorError> {
2562 let [anchor, focus] = self.active_outline().selection.positions;
2563 if !self
2564 .active_outline()
2565 .document
2566 .crosses_table(anchor.min(focus)..anchor.max(focus))?
2567 {
2568 return Ok(None);
2569 }
2570 let depth = self.undo.len();
2571 let result = self.delete(engine, false).and_then(|_| edit(self, engine));
2572 let entries = self.undo.split_off(depth);
2573 if !entries.is_empty() {
2574 self.undo.push(History::Group {
2575 entries,
2576 page: false,
2577 });
2578 }
2579 result.map(Some)
2580 }
2581
2487 /// Runs `edit`, typing or a backspace at a caret, as part of the typing run the last undo2582 /// Runs `edit`, typing or a backspace at a caret, as part of the typing run the last undo
2488 /// step holds if the caret is where that run left it and nothing came between; OneNote 20102583 /// step holds if the caret is where that run left it and nothing came between; OneNote 2010
2489 /// undoes a run whole however long it pauses.2584 /// undoes a run whole however long it pauses.
...@@ -3149,6 +3244,21 @@ impl CanvasEditor {...@@ -3149,6 +3244,21 @@ impl CanvasEditor {
3149 )3244 )
3150 }3245 }
31513246
3247 /// Replaces the selection with `text` in the formatting of the text it replaces, as a
3248 /// spelling correction does.
3249 pub fn correct(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
3250 let [anchor, focus] = self.selection().positions;
3251 let end = anchor.max(focus);
3252 let format = self
3253 .active_outline()
3254 .document
3255 .paragraph(end.paragraph)
3256 .ok_or(EditError::InvalidRange)?
3257 .format_at(end.offset)?
3258 .clone();
3259 self.replace(engine, vec![Paragraph::new(text.to_owned(), format)])
3260 }
3261
3152 /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line3262 /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line
3153 /// of text joins the typing run before it.3263 /// of text joins the typing run before it.
3154 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {3264 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
...@@ -3210,6 +3320,11 @@ impl CanvasEditor {...@@ -3210,6 +3320,11 @@ impl CanvasEditor {
3210 format.language = Some(language);3320 format.language = Some(language);
3211 return self.replace(engine, vec![Paragraph::new(last.to_owned(), format)]);3321 return self.replace(engine, vec![Paragraph::new(last.to_owned(), format)]);
3212 }3322 }
3323 if let Some(pasted) = self.across(engine, |editor, engine| {
3324 editor.paste(engine, text, language)
3325 })? {
3326 return Ok(pasted);
3327 }
3213 let edge = Paragraph::new(String::new(), self.typing_format(start)?);3328 let edge = Paragraph::new(String::new(), self.typing_format(start)?);
3214 let pasted = Format {3329 let pasted = Format {
3215 font: Some("Calibri".into()),3330 font: Some("Calibri".into()),
...@@ -3252,6 +3367,9 @@ impl CanvasEditor {...@@ -3252,6 +3367,9 @@ impl CanvasEditor {
3252 /// start its tags stay with its text below; an empty paragraph opens a plain one above and3367 /// start its tags stay with its text below; an empty paragraph opens a plain one above and
3253 /// leaves the list it ends; a heading continues as body text.3368 /// leaves the list it ends; a heading continues as body text.
3254 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {3369 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
3370 if let Some(split) = self.across(engine, Self::split)? {
3371 return Ok(split);
3372 }
3255 let [anchor, focus] = self.active_outline().selection.positions;3373 let [anchor, focus] = self.active_outline().selection.positions;
3256 let (start, end) = (anchor.min(focus), anchor.max(focus));3374 let (start, end) = (anchor.min(focus), anchor.max(focus));
3257 let mut format = self.typing_format(start)?;3375 let mut format = self.typing_format(start)?;
...@@ -3339,8 +3457,9 @@ impl CanvasEditor {...@@ -3339,8 +3457,9 @@ impl CanvasEditor {
3339 .document3457 .document
3340 .leaf(end.paragraph)3458 .leaf(end.paragraph)
3341 .ok_or(EditError::InvalidRange)?;3459 .ok_or(EditError::InvalidRange)?;
3460 // OneNote 2010's Tab leaves a selection crossing a table's edge alone.
3342 if container != end_container {3461 if container != end_container {
3343 return Err(EditError::UnsupportedContent.into());3462 return Ok(false);
3344 }3463 }
3345 let range = local_start3464 let range = local_start
3346 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);3465 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);
...@@ -3617,6 +3736,7 @@ impl CanvasEditor {...@@ -3617,6 +3736,7 @@ impl CanvasEditor {
3617 };3736 };
3618 match self.apply_history(engine, history) {3737 match self.apply_history(engine, history) {
3619 Ok(inverse) => {3738 Ok(inverse) => {
3739 self.unstore_provisional();
3620 let page = matches!(inverse, History::Group { page: true, .. });3740 let page = matches!(inverse, History::Group { page: true, .. });
3621 self.redo.push(inverse);3741 self.redo.push(inverse);
3622 if page {3742 if page {
...@@ -3631,6 +3751,37 @@ impl CanvasEditor {...@@ -3631,6 +3751,37 @@ impl CanvasEditor {
3631 }3751 }
3632 }3752 }
36333753
3754 /// Removes from storage each stored provisional paragraph an undo left empty and the only
3755 /// text of its outline, which shows it still.
3756 fn unstore_provisional(&mut self) {
3757 let removed: Vec<ExGuid> = self
3758 .provisional
3759 .iter()
3760 .filter(|(outline, (blank, stored))| {
3761 let Some(outline) = self.outlines.iter().find(|item| item.id == **outline) else {
3762 return false;
3763 };
3764 let mut leaves = crate::document::leaves(outline.document.nodes(), None);
3765 *stored
3766 && matches!(
3767 (leaves.next(), leaves.next()),
3768 (Some((None, _, node)), None) if node.id == blank.id
3769 && node.tags.is_empty()
3770 && node.lists.is_empty()
3771 && node.text().is_some_and(|text| {
3772 text.text.text().is_empty() && text.tags.is_empty()
3773 })
3774 )
3775 })
3776 .map(|(outline, _)| *outline)
3777 .collect();
3778 for outline in removed {
3779 let (blank, _) = &self.provisional[&outline];
3780 self.record(Ok(vec![PageOp::Delete { object: blank.id }]));
3781 self.provisional.get_mut(&outline).unwrap().1 = false;
3782 }
3783 }
3784
3634 pub fn redo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {3785 pub fn redo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
3635 self.finish_composition();3786 self.finish_composition();
3636 let Some(history) = self.redo.pop() else {3787 let Some(history) = self.redo.pop() else {
...@@ -5994,8 +6145,10 @@ mod tests {...@@ -5994,8 +6145,10 @@ mod tests {
5994 ..Default::default()6145 ..Default::default()
5995 },6146 },
5996 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),6147 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
6148 display: None,
5997 alt: None,6149 alt: None,
5998 background,6150 background,
6151 printout: None,
5999 });6152 });
6000 let (id, background_id) = (picture.id, background.id);6153 let (id, background_id) = (picture.id, background.id);
6001 let mut editor = CanvasEditor::from_page(6154 let mut editor = CanvasEditor::from_page(
...@@ -6100,8 +6253,10 @@ mod tests {...@@ -6100,8 +6253,10 @@ mod tests {
6100 ..Default::default()6253 ..Default::default()
6101 },6254 },
6102 bytes: Some(std::sync::Arc::from(bytes)),6255 bytes: Some(std::sync::Arc::from(bytes)),
6256 display: None,
6103 alt: None,6257 alt: None,
6104 background,6258 background,
6259 printout: None,
6105 };6260 };
6106 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];6261 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];
6107 let mut editor = CanvasEditor::from_page(6262 let mut editor = CanvasEditor::from_page(
...@@ -6187,8 +6342,10 @@ mod tests {...@@ -6187,8 +6342,10 @@ mod tests {
6187 ..Default::default()6342 ..Default::default()
6188 },6343 },
6189 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),6344 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
6345 display: None,
6190 alt: None,6346 alt: None,
6191 background: false,6347 background: false,
6348 printout: None,
6192 };6349 };
6193 let id = picture.id;6350 let id = picture.id;
6194 let objects = vec![6351 let objects = vec![
...@@ -6250,8 +6407,10 @@ mod tests {...@@ -6250,8 +6407,10 @@ mod tests {
6250 id: onestore::page::text::new_id().unwrap(),6407 id: onestore::page::text::new_id().unwrap(),
6251 layout: Default::default(),6408 layout: Default::default(),
6252 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),6409 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
6410 display: None,
6253 alt: None,6411 alt: None,
6254 background: false,6412 background: false,
6413 printout: None,
6255 };6414 };
6256 picture.content = ParagraphContent::Image(image.clone());6415 picture.content = ParagraphContent::Image(image.clone());
6257 source.paragraphs.insert(1, picture);6416 source.paragraphs.insert(1, picture);
...@@ -6423,7 +6582,9 @@ mod tests {...@@ -6423,7 +6582,9 @@ mod tests {
64236582
6424 editor.select(at(0, 3)).unwrap();6583 editor.select(at(0, 3)).unwrap();
6425 let middle = file("middle.txt");6584 let middle = file("middle.txt");
6426 editor.insert_attachment(&mut engine, middle.clone()).unwrap();6585 editor
6586 .insert_attachment(&mut engine, middle.clone())
6587 .unwrap();
6427 assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]);6588 assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]);
6428 // A file is no text position: the caret starts "ore", the second text.6589 // A file is no text position: the caret starts "ore", the second text.
6429 assert_eq!(editor.selection().positions, at(1, 0).positions);6590 assert_eq!(editor.selection().positions, at(1, 0).positions);
...@@ -6433,7 +6594,9 @@ mod tests {...@@ -6433,7 +6594,9 @@ mod tests {
6433 if paragraphs.iter().any(|p| matches!(&p.content, ParagraphContent::Attachment(a) if *a == middle)))));6594 if paragraphs.iter().any(|p| matches!(&p.content, ParagraphContent::Attachment(a) if *a == middle)))));
64346595
6435 editor.select(at(2, 0)).unwrap();6596 editor.select(at(2, 0)).unwrap();
6436 editor.insert_attachment(&mut engine, file("empty.bin")).unwrap();6597 editor
6598 .insert_attachment(&mut engine, file("empty.bin"))
6599 .unwrap();
6437 assert_eq!(6600 assert_eq!(
6438 shown(&editor),6601 shown(&editor),
6439 ["Bef", "[middle.txt]", "ore", "[empty.bin]", ""]6602 ["Bef", "[middle.txt]", "ore", "[empty.bin]", ""]
...@@ -6447,7 +6610,7 @@ mod tests {...@@ -6447,7 +6610,7 @@ mod tests {
6447 }6610 }
64486611
6449 #[test]6612 #[test]
6450 fn unknown_paragraphs_and_text_free_outlines_stay_on_the_page() {6613 fn unknown_paragraphs_stay_on_the_page_and_text_free_outlines_take_a_caret() {
6451 use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported};6614 use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported};
6452 let mut engine = TextEngine::default();6615 let mut engine = TextEngine::default();
6453 let outline = |engine: &mut TextEngine| {6616 let outline = |engine: &mut TextEngine| {
...@@ -6481,8 +6644,10 @@ mod tests {...@@ -6481,8 +6644,10 @@ mod tests {
6481 id: onestore::page::text::new_id().unwrap(),6644 id: onestore::page::text::new_id().unwrap(),
6482 layout: Default::default(),6645 layout: Default::default(),
6483 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),6646 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
6647 display: None,
6484 alt: None,6648 alt: None,
6485 background: false,6649 background: false,
6650 printout: None,
6486 });6651 });
6487 let (mixed_id, picture_id) = (mixed.id, picture_only.id);6652 let (mixed_id, picture_id) = (mixed.id, picture_only.id);
6488 let editor = CanvasEditor::from_page(6653 let editor = CanvasEditor::from_page(
...@@ -6512,11 +6677,15 @@ mod tests {...@@ -6512,11 +6677,15 @@ mod tests {
6512 ));6677 ));
6513 assert_eq!(placeholder.rect[2] - placeholder.rect[0], 160.0);6678 assert_eq!(placeholder.rect[2] - placeholder.rect[0], 160.0);
6514 assert!(mixed.shaped().paragraphs[1].origin[1] >= placeholder.bottom);6679 assert!(mixed.shaped().paragraphs[1].origin[1] >= placeholder.bottom);
6515 assert!(editor.objects.iter().any(|object| matches!(6680 // The picture's outline takes a paragraph for the caret, which the page leaves out
6681 // until it is typed in.
6682 assert!(editor.has_page_outline(picture_id));
6683 let page = editor.page().unwrap();
6684 assert_eq!(page.objects.len(), 2);
6685 assert!(page.objects.iter().any(|object| matches!(
6516 object,6686 object,
6517 page::Content::Outline { source, .. } if source.id == picture_id6687 PageObject::Outline(outline) if outline.id == picture_id && outline.paragraphs.len() == 1
6518 )));6688 )));
6519 assert_eq!(editor.page().unwrap().objects.len(), 2);
6520 }6689 }
65216690
6522 #[test]6691 #[test]
...@@ -6560,8 +6729,10 @@ mod tests {...@@ -6560,8 +6729,10 @@ mod tests {
6560 ..Default::default()6729 ..Default::default()
6561 },6730 },
6562 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),6731 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
6732 display: None,
6563 alt: None,6733 alt: None,
6564 background,6734 background,
6735 printout: None,
6565 });6736 });
6566 let image_ids = images.each_ref().map(|image| image.id);6737 let image_ids = images.each_ref().map(|image| image.id);
6567 let image_bytes = images6738 let image_bytes = images
...@@ -9136,8 +9307,10 @@ mod tests {...@@ -9136,8 +9307,10 @@ mod tests {
9136 },9307 },
9137 size: None,9308 size: None,
9138 bytes: None,9309 bytes: None,
9310 display: None,
9139 alt: None,9311 alt: None,
9140 background: false,9312 background: false,
9313 printout: None,
9141 });9314 });
9142 if objects {9315 if objects {
9143 let selection = [TextPosition {9316 let selection = [TextPosition {
crates/canvas/src/editor/equation.rs+45-21
...@@ -119,28 +119,52 @@ impl CanvasEditor {...@@ -119,28 +119,52 @@ impl CanvasEditor {
119 self.pending = Some((outline.id, start, format));119 self.pending = Some((outline.id, start, format));
120 return Ok(());120 return Ok(());
121 }121 }
122 if start.paragraph != end.paragraph {122 // Across paragraphs each paragraph's part becomes an equation of its own, as in
123 return Err(EditError::UnsupportedContent.into());123 // OneNote 2010, in one undo step.
124 let depth = self.undo.len();
125 let result = (|| {
126 for paragraph in start.paragraph..=end.paragraph {
127 let text = self
128 .active_outline()
129 .document
130 .paragraph(paragraph)
131 .ok_or(EditError::InvalidRange)?;
132 let from = if paragraph == start.paragraph {
133 start.offset
134 } else {
135 0
136 };
137 let to = if paragraph == end.paragraph {
138 end.offset
139 } else {
140 text.utf16_offset(text.text().len())?
141 };
142 if from == to {
143 continue;
144 }
145 let linear = text
146 .slice(from..to)?
147 .project()?
148 .text()
149 .text()
150 .replace('\n', " ");
151 let base = format_after(text, from).cloned().unwrap_or_default();
152 let math = Math::paragraph(&Math::from_linear(&linear), &base);
153 let caret = from + math.utf16_offset(math.text().len())?;
154 self.replace_zone(engine, paragraph, from..to, math, caret)?;
155 }
156 Ok(())
157 })();
158 let entries = self.undo.split_off(depth);
159 match entries.len() {
160 0 => {}
161 1 => self.undo.extend(entries),
162 _ => self.undo.push(History::Group {
163 entries,
164 page: false,
165 }),
124 }166 }
125 let text = outline167 result
126 .document
127 .paragraph(start.paragraph)
128 .ok_or(EditError::InvalidRange)?;
129 let selected = text.slice(start.offset..end.offset)?;
130 let linear = selected.project()?.text().text().replace('\n', " ");
131 let base = format_after(text, start.offset)
132 .cloned()
133 .unwrap_or_default();
134 let math = Math::paragraph(&Math::from_linear(&linear), &base);
135 let caret = start.offset + math.utf16_offset(math.text().len())?;
136 self.replace_zone(
137 engine,
138 start.paragraph,
139 start.offset..end.offset,
140 math,
141 caret,
142 )?;
143 Ok(())
144 }168 }
145169
146 /// Builds the equation up after the space typed at `typed` ended part of it, as the170 /// Builds the equation up after the space typed at `typed` ended part of it, as the
crates/canvas/src/editor/format.rs+67-4
...@@ -174,6 +174,10 @@ pub struct NoteTag {...@@ -174,6 +174,10 @@ pub struct NoteTag {
174 /// COLORREFs; none leaves the text's own.174 /// COLORREFs; none leaves the text's own.
175 pub color: Option<u32>,175 pub color: Option<u32>,
176 pub highlight: Option<u32>,176 pub highlight: Option<u32>,
177 /// Snowbound's own art for the tag, named for its content, which a notebook's
178 /// `.snowbound` folder maps the tag's name and symbol to; OneNote draws the symbol.
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub art: Option<String>,
177}181}
178182
179/// OneNote 2010's tag list before it is customized, the first nine on Ctrl+1 to Ctrl+9183/// OneNote 2010's tag list before it is customized, the first nine on Ctrl+1 to Ctrl+9
...@@ -220,10 +224,19 @@ impl NoteTag {...@@ -220,10 +224,19 @@ impl NoteTag {
220 // The highlighting tags also set black text.224 // The highlighting tags also set black text.
221 color: highlight.map(|_| 0),225 color: highlight.map(|_| 0),
222 highlight,226 highlight,
227 art: None,
223 })228 })
224 .collect()229 .collect()
225 }230 }
226231
232 /// The tag as its definition stores it, without Snowbound's art.
233 pub fn stored(&self) -> Self {
234 Self {
235 art: None,
236 ..self.clone()
237 }
238 }
239
227 /// The definition OneNote stores for the tag at place `action_type` in the list.240 /// The definition OneNote stores for the tag at place `action_type` in the list.
228 pub fn definition(&self, action_type: u16) -> Definition {241 pub fn definition(&self, action_type: u16) -> Definition {
229 Definition {242 Definition {
...@@ -256,6 +269,7 @@ impl NoteTag {...@@ -256,6 +269,7 @@ impl NoteTag {
256 shape: shape.unwrap_or(0),269 shape: shape.unwrap_or(0),
257 color: *color,270 color: *color,
258 highlight: *highlight,271 highlight: *highlight,
272 art: None,
259 },273 },
260 action_type.unwrap_or(0),274 action_type.unwrap_or(0),
261 ))275 ))
...@@ -905,7 +919,7 @@ impl CanvasEditor {...@@ -905,7 +919,7 @@ impl CanvasEditor {
905 Formatting::Toggle(toggle) => state.toggles.contains(toggle),919 Formatting::Toggle(toggle) => state.toggles.contains(toggle),
906 Formatting::Bullets => state.bullets,920 Formatting::Bullets => state.bullets,
907 Formatting::Numbering => state.numbering,921 Formatting::Numbering => state.numbering,
908 Formatting::Tag(tag, action_type) => state.tags.contains(&(tag.clone(), *action_type)),922 Formatting::Tag(tag, action_type) => state.tags.contains(&(tag.stored(), *action_type)),
909 _ => false,923 _ => false,
910 };924 };
911 let mut outlines = Vec::new();925 let mut outlines = Vec::new();
...@@ -939,9 +953,10 @@ impl CanvasEditor {...@@ -939,9 +953,10 @@ impl CanvasEditor {
939 self.focus_outline(outline)?;953 self.focus_outline(outline)?;
940 let outline = self.active_outline();954 let outline = self.active_outline();
941 let document = &outline.document;955 let document = &outline.document;
942 let paragraph = leaves(document.nodes(), None)956 let Some(paragraph) = leaves(document.nodes(), None).position(|(_, _, node)| node.id == id)
943 .position(|(_, _, node)| node.id == id)957 else {
944 .ok_or(EditError::InvalidRange)?;958 return self.click_block_check(engine, id);
959 };
945 let at = TextPosition {960 let at = TextPosition {
946 paragraph,961 paragraph,
947 offset: 0,962 offset: 0,
...@@ -965,6 +980,53 @@ impl CanvasEditor {...@@ -965,6 +980,53 @@ impl CanvasEditor {
965 )980 )
966 }981 }
967982
983 /// A click on the check box of a table, picture or file paragraph `id`.
984 fn click_block_check(
985 &mut self,
986 engine: &mut TextEngine,
987 id: ExGuid,
988 ) -> Result<(), EditorError> {
989 let outline = self.active_outline();
990 let (container, index, node) = descendants(outline.document.nodes(), None)
991 .find(|(_, _, node)| node.id == id)
992 .ok_or(EditError::InvalidRange)?;
993 let mut node = node.clone();
994 let checkable = |tag: &Tag| {
995 matches!(
996 self.tag_kind(tag),
997 Some(Kind::TagDefinition { shape: Some(shape), .. })
998 if crate::outline::checkable(*shape)
999 )
1000 };
1001 let content = match &mut node.content {
1002 ParagraphContent::Table(table) => table.tags.as_mut_slice(),
1003 _ => &mut [],
1004 };
1005 let mut tags: Vec<&mut Tag> = node.tags.iter_mut().chain(content).collect();
1006 let checked = tags
1007 .iter()
1008 .filter(|tag| checkable(tag))
1009 .all(|tag| tag.status & 1 != 0);
1010 let completed = if checked { Some(0) } else { time32() };
1011 for tag in &mut tags {
1012 if checkable(tag) && (tag.status & 1 != 0) == checked {
1013 tag.status ^= 1;
1014 tag.completed = completed;
1015 }
1016 }
1017 let selection = outline.selection;
1018 self.commit(
1019 engine,
1020 DocumentEdit {
1021 columns: BTreeMap::new(),
1022 container,
1023 range: index..index + 1,
1024 replacement: vec![node],
1025 },
1026 selection,
1027 )
1028 }
1029
968 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are1030 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
969 /// checked; OneNote keeps a cleared box's completion time as zero.1031 /// checked; OneNote keeps a cleared box's completion time as zero.
970 fn check(1032 fn check(
...@@ -1976,6 +2038,7 @@ mod tests {...@@ -1976,6 +2038,7 @@ mod tests {
1976 shape: 61,2038 shape: 61,
1977 color: Some(0x0000_0080),2039 color: Some(0x0000_0080),
1978 highlight: Some(0x00ff_cc00),2040 highlight: Some(0x00ff_cc00),
2041 art: None,
1979 };2042 };
1980 editor2043 editor
1981 .format(&mut engine, Formatting::Tag(tag.clone(), 0))2044 .format(&mut engine, Formatting::Tag(tag.clone(), 0))
crates/canvas/src/editor/ink.rs+9-3
...@@ -6,7 +6,11 @@ use super::{CanvasEditor, EditError, EditorError, History, Placement, page};...@@ -6,7 +6,11 @@ use super::{CanvasEditor, EditError, EditorError, History, Placement, page};
6use onestore::{6use onestore::{
7 ExGuid,7 ExGuid,
8 op::PageOp,8 op::PageOp,
9 page::{Ink, InkStroke, PageObject, ink::snap, text::new_id},9 page::{
10 Ink, InkStroke, PageObject,
11 ink::{level, snap},
12 text::new_id,
13 },
10};14};
1115
12/// A pen or highlighter, as OneNote's pen gallery and Color & Thickness choose one.16/// A pen or highlighter, as OneNote's pen gallery and Color & Thickness choose one.
...@@ -47,8 +51,9 @@ impl Pen {...@@ -47,8 +51,9 @@ impl Pen {
47 }51 }
48 }52 }
4953
50 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.54 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored, its width
51 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {55 /// following `pressure` at each point unless that is empty, as a mouse's is.
56 pub fn stroke(&self, points: &[[f32; 2]], pressure: &[f32]) -> Result<InkStroke, EditError> {
52 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);57 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);
53 Ok(InkStroke {58 Ok(InkStroke {
54 id: new_id()?,59 id: new_id()?,
...@@ -63,6 +68,7 @@ impl Pen {...@@ -63,6 +68,7 @@ impl Pen {
63 transparency: self.highlighter.then_some(127),68 transparency: self.highlighter.then_some(127),
64 pen_tip: self.highlighter.then_some(1),69 pen_tip: self.highlighter.then_some(1),
65 raster_operation: self.highlighter.then_some(9),70 raster_operation: self.highlighter.then_some(9),
71 pressure: pressure.iter().copied().map(level).collect(),
66 })72 })
67 }73 }
68}74}
crates/canvas/src/editor/link.rs+38-15
...@@ -175,6 +175,10 @@ fn field_code(address: &str) -> String {...@@ -175,6 +175,10 @@ fn field_code(address: &str) -> String {
175 format!("\u{fddf}HYPERLINK \"{address}\"")175 format!("\u{fddf}HYPERLINK \"{address}\"")
176}176}
177177
178/// A range of a paragraph the Link dialog links, with the text it shows and the address it
179/// holds.
180type Linked = (usize, Range<u32>, String, String);
181
178impl CanvasEditor {182impl CanvasEditor {
179 /// The link in the active outline at `position`, its ends included.183 /// The link in the active outline at `position`, its ends included.
180 pub fn link_at(&self, position: TextPosition) -> Option<Link> {184 pub fn link_at(&self, position: TextPosition) -> Option<Link> {
...@@ -234,12 +238,13 @@ impl CanvasEditor {...@@ -234,12 +238,13 @@ impl CanvasEditor {
234238
235 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one239 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
236 /// paragraph, else the link or word at the caret, else nothing at the caret. With the240 /// paragraph, else the link or word at the caret, else nothing at the caret. With the
237 /// text it shows and the address it holds.241 /// text it shows and the address it holds; none for a selection across paragraphs, which
238 fn link_range(&self) -> Result<(usize, Range<u32>, String, String), EditError> {242 /// OneNote's Link leaves alone.
243 fn link_range(&self) -> Result<Option<Linked>, EditError> {
239 let [anchor, focus] = self.active_outline().selection.positions;244 let [anchor, focus] = self.active_outline().selection.positions;
240 let (start, end) = (anchor.min(focus), anchor.max(focus));245 let (start, end) = (anchor.min(focus), anchor.max(focus));
241 if start.paragraph != end.paragraph {246 if start.paragraph != end.paragraph {
242 return Err(EditError::UnsupportedContent);247 return Ok(None);
243 }248 }
244 let text = self249 let text = self
245 .active_outline()250 .active_outline()
...@@ -263,11 +268,16 @@ impl CanvasEditor {...@@ -263,11 +268,16 @@ impl CanvasEditor {
263 .filter(|link| link.range().start <= range.start && range.end <= link.label.end)268 .filter(|link| link.range().start <= range.start && range.end <= link.label.end)
264 .map(|link| link.target)269 .map(|link| link.target)
265 .unwrap_or_default();270 .unwrap_or_default();
266 return Ok((start.paragraph, range.clone(), shown(range)?, address));271 return Ok(Some((
272 start.paragraph,
273 range.clone(),
274 shown(range)?,
275 address,
276 )));
267 }277 }
268 if let Some(link) = self.link_at(start) {278 if let Some(link) = self.link_at(start) {
269 let label = shown(link.label.clone())?;279 let label = shown(link.label.clone())?;
270 return Ok((start.paragraph, link.range(), label, link.target));280 return Ok(Some((start.paragraph, link.range(), label, link.target)));
271 }281 }
272 // The word at the caret, within plain visible text.282 // The word at the caret, within plain visible text.
273 let byte = text.byte_offset(start.offset)?;283 let byte = text.byte_offset(start.offset)?;
...@@ -293,14 +303,22 @@ impl CanvasEditor {...@@ -293,14 +303,22 @@ impl CanvasEditor {
293 .last()303 .last()
294 .map_or(byte, |(at, c)| byte + at + c.len_utf8());304 .map_or(byte, |(at, c)| byte + at + c.len_utf8());
295 let range = text.utf16_offset(from)?..text.utf16_offset(to)?;305 let range = text.utf16_offset(from)?..text.utf16_offset(to)?;
296 Ok((start.paragraph, range.clone(), shown(range)?, String::new()))306 Ok(Some((
307 start.paragraph,
308 range.clone(),
309 shown(range)?,
310 String::new(),
311 )))
297 }312 }
298313
299 /// The text and address the Link dialog opens with.314 /// The text and address the Link dialog opens with; none where it does not open, over a
300 pub fn link_prefill(&self) -> (String, String) {315 /// selection across paragraphs.
301 self.link_range()316 pub fn link_prefill(&self) -> Option<(String, String)> {
302 .map(|(_, _, text, address)| (text, address))317 match self.link_range() {
303 .unwrap_or_default()318 Ok(Some((_, _, text, address))) => Some((text, address)),
319 Ok(None) => None,
320 Err(_) => Some(Default::default()),
321 }
304 }322 }
305323
306 /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the324 /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the
...@@ -314,7 +332,9 @@ impl CanvasEditor {...@@ -314,7 +332,9 @@ impl CanvasEditor {
314 if address.trim().is_empty() {332 if address.trim().is_empty() {
315 return Ok(());333 return Ok(());
316 }334 }
317 let (paragraph, range, _, _) = self.link_range()?;335 let Some((paragraph, range, _, _)) = self.link_range()? else {
336 return Ok(());
337 };
318 let current = self338 let current = self
319 .active_outline()339 .active_outline()
320 .document340 .document
...@@ -601,7 +621,7 @@ mod tests {...@@ -601,7 +621,7 @@ mod tests {
601 typed(&mut editor, engine, "Zplain words here");621 typed(&mut editor, engine, "Zplain words here");
602 let paragraph = editor.selection().positions[0].paragraph;622 let paragraph = editor.selection().positions[0].paragraph;
603 editor.select(at(paragraph, 13)).unwrap();623 editor.select(at(paragraph, 13)).unwrap();
604 assert_eq!(editor.link_prefill(), ("here".into(), String::new()));624 assert_eq!(editor.link_prefill(), Some(("here".into(), String::new())));
605 editor.set_link(engine, "here", "example.net/x").unwrap();625 editor.set_link(engine, "here", "example.net/x").unwrap();
606 typed(&mut editor, engine, "Q");626 typed(&mut editor, engine, "Q");
607 editor.enter(engine, false).unwrap();627 editor.enter(engine, false).unwrap();
...@@ -622,12 +642,15 @@ mod tests {...@@ -622,12 +642,15 @@ mod tests {
622 .into(),642 .into(),
623 )643 )
624 .unwrap();644 .unwrap();
625 assert_eq!(editor.link_prefill(), ("me please".into(), String::new()));645 assert_eq!(
646 editor.link_prefill(),
647 Some(("me please".into(), String::new()))
648 );
626 editor649 editor
627 .set_link(engine, "me please", "https://example.com/selected")650 .set_link(engine, "me please", "https://example.com/selected")
628 .unwrap();651 .unwrap();
629 editor.enter(engine, false).unwrap();652 editor.enter(engine, false).unwrap();
630 assert_eq!(editor.link_prefill(), (String::new(), String::new()));653 assert_eq!(editor.link_prefill(), Some((String::new(), String::new())));
631 editor654 editor
632 .set_link(engine, "", "https://example.com/only")655 .set_link(engine, "", "https://example.com/only")
633 .unwrap();656 .unwrap();
crates/canvas/src/editor/ops.rs+60-4
...@@ -15,6 +15,44 @@ fn stale(_: EditError) -> onestore::Error {...@@ -15,6 +15,44 @@ fn stale(_: EditError) -> onestore::Error {
15 refused("The editor lost track of the stored page")15 refused("The editor lost track of the stored page")
16}16}
1717
18/// Whether `op` needs paragraph `blank`, last in `outline` and not stored yet: it edits the
19/// paragraph or its text, or adds to or moves within the outline's top level.
20fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool {
21 let text = blank.text().map(|text| text.id);
22 let own = |id: &ExGuid| *id == blank.id || Some(*id) == text;
23 match op {
24 PageOp::Text { text, .. }
25 | PageOp::Format { text, .. }
26 | PageOp::Link { text, .. }
27 | PageOp::Equation { text, .. }
28 | PageOp::Split { text, .. } => own(text),
29 PageOp::Join { left, right } => own(left) || own(right),
30 PageOp::Insert {
31 container, before, ..
32 } => *container == outline || before.as_ref().is_some_and(own),
33 PageOp::Move {
34 object,
35 parent,
36 before,
37 } => {
38 own(object)
39 || parent
40 .as_ref()
41 .is_some_and(|parent| *parent == outline || own(parent))
42 || before.as_ref().is_some_and(own)
43 }
44 PageOp::Delete { object: id }
45 | PageOp::Level { paragraph: id, .. }
46 | PageOp::Outline { object: id, .. }
47 | PageOp::Paragraph { paragraph: id, .. }
48 | PageOp::Style { paragraph: id, .. }
49 | PageOp::Media { paragraph: id, .. }
50 | PageOp::List { paragraph: id, .. }
51 | PageOp::Tags { target: id, .. } => own(id),
52 _ => false,
53 }
54}
55
18/// Definitions a paragraph names: its lists, style and note tags.56/// Definitions a paragraph names: its lists, style and note tags.
19pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + '_ {57pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + '_ {
20 let content_tags = match &node.content {58 let content_tags = match &node.content {
...@@ -266,12 +304,30 @@ impl CanvasEditor {...@@ -266,12 +304,30 @@ impl CanvasEditor {
266 taken304 taken
267 }305 }
268306
307 /// Queues `lowered`, storing first the provisional paragraph of an outline an op reaches.
269 pub(super) fn record(&mut self, lowered: Lowered) {308 pub(super) fn record(&mut self, lowered: Lowered) {
270 if let Ok(ops) = &mut self.ops {309 let Ok(ops) = &mut self.ops else {
271 match lowered {310 return;
272 Ok(mut lowered) => ops.append(&mut lowered),311 };
273 Err(error) => self.ops = Err(error),312 let lowered = match lowered {
313 Ok(lowered) => lowered,
314 Err(error) => {
315 self.ops = Err(error);
316 return;
317 }
318 };
319 for op in lowered {
320 for (outline, (blank, stored)) in &mut self.provisional {
321 if !*stored && reaches(&op, *outline, blank) {
322 *stored = true;
323 ops.push(PageOp::Insert {
324 container: *outline,
325 before: None,
326 paragraphs: vec![blank.clone()],
327 });
328 }
274 }329 }
330 ops.push(op);
275 }331 }
276 }332 }
277333
crates/canvas/src/editor/page.rs+36-5
...@@ -5,7 +5,7 @@ use crate::{...@@ -5,7 +5,7 @@ use crate::{
5 outline::{Arrange, OutlineLayout},5 outline::{Arrange, OutlineLayout},
6};6};
7use onestore::page::text::Paragraph;7use onestore::page::text::Paragraph;
8use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject};8use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject, PageParagraph};
9use std::collections::BTreeMap;9use std::collections::BTreeMap;
1010
11/// A title object's own state, plus the child origins `build` replaces with page coordinates.11/// A title object's own state, plus the child origins `build` replaces with page coordinates.
...@@ -21,6 +21,8 @@ pub(crate) struct Import {...@@ -21,6 +21,8 @@ pub(crate) struct Import {
21 pub outlines: Vec<TextOutline>,21 pub outlines: Vec<TextOutline>,
22 pub date: Option<PageDate>,22 pub date: Option<PageDate>,
23 pub areas: Vec<TitleArea>,23 pub areas: Vec<TitleArea>,
24 /// The paragraph each outline holding no text shows after its objects, not stored.
25 pub provisional: BTreeMap<onestore::ExGuid, PageParagraph>,
24}26}
2527
26pub(crate) enum Content {28pub(crate) enum Content {
...@@ -184,6 +186,7 @@ pub(crate) fn build(...@@ -184,6 +186,7 @@ pub(crate) fn build(
184 let mut outlines = Vec::new();186 let mut outlines = Vec::new();
185 let mut areas = Vec::new();187 let mut areas = Vec::new();
186 let mut date = None;188 let mut date = None;
189 let mut provisional = BTreeMap::new();
187 for object in std::mem::take(&mut page.objects) {190 for object in std::mem::take(&mut page.objects) {
188 match &object {191 match &object {
189 PageObject::Outline(outline) => {192 PageObject::Outline(outline) => {
...@@ -202,7 +205,28 @@ pub(crate) fn build(...@@ -202,7 +205,28 @@ pub(crate) fn build(
202 )205 )
203 };206 };
204 if editable {207 if editable {
205 match TextOutline::from_outline(engine, outline, &page.definitions) {208 // Pictures and files alone in an outline take a paragraph after them where
209 // a click beside them lands, as OneNote 2010 adds one when typing there.
210 let extended;
211 let source = if crate::document::leaves(&outline.paragraphs, None)
212 .next()
213 .is_none()
214 {
215 let blank = crate::document::node(
216 Paragraph::new(String::new(), Default::default()),
217 Default::default(),
218 )?;
219 extended = Outline {
220 paragraphs: [&outline.paragraphs[..], std::slice::from_ref(&blank)]
221 .concat(),
222 ..outline.clone()
223 };
224 provisional.insert(outline.id, blank);
225 &extended
226 } else {
227 outline
228 };
229 match TextOutline::from_outline(engine, source, &page.definitions) {
206 Ok(outline) => {230 Ok(outline) => {
207 objects.push(Content::Editable(outline.id));231 objects.push(Content::Editable(outline.id));
208 outlines.push(outline);232 outlines.push(outline);
...@@ -210,7 +234,9 @@ pub(crate) fn build(...@@ -210,7 +234,9 @@ pub(crate) fn build(
210 }234 }
211 Err(error) if !unsupported(&error) => return Err(error),235 Err(error) if !unsupported(&error) => return Err(error),
212 // Drawn as stored when the editor cannot hold it.236 // Drawn as stored when the editor cannot hold it.
213 Err(_) => {}237 Err(_) => {
238 provisional.remove(&outline.id);
239 }
214 }240 }
215 }241 }
216 match outline.layout(engine, &page.definitions) {242 match outline.layout(engine, &page.definitions) {
...@@ -396,6 +422,7 @@ pub(crate) fn build(...@@ -396,6 +422,7 @@ pub(crate) fn build(
396 outlines,422 outlines,
397 date,423 date,
398 areas,424 areas,
425 provisional,
399 })426 })
400}427}
401428
...@@ -415,11 +442,15 @@ fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> {...@@ -415,11 +442,15 @@ fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> {
415 ink.strokes442 ink.strokes
416 .iter()443 .iter()
417 .flat_map(|stroke| {444 .flat_map(|stroke| {
418 let r = stroke.width.max(stroke.height) * 0.5;445 let size = stroke.width.max(stroke.height) * 0.5;
419 stroke446 stroke
420 .points447 .points
421 .iter()448 .iter()
422 .map(move |[x, y]| [x - r, y - r, x + r, y + r])449 .enumerate()
450 .map(move |(index, [x, y])| {
451 let r = size * stroke.thickness(index);
452 [x - r, y - r, x + r, y + r]
453 })
423 })454 })
424 .chain(ink.groups.iter().filter_map(stroke_bounds))455 .chain(ink.groups.iter().filter_map(stroke_bounds))
425 .reduce(|a, b| {456 .reduce(|a, b| {
crates/canvas/src/editor/recording.rs+6-1
...@@ -88,8 +88,13 @@ impl CanvasEditor {...@@ -88,8 +88,13 @@ impl CanvasEditor {
88 if self.recording.is_some() {88 if self.recording.is_some() {
89 return Err(EditError::InvalidRange.into());89 return Err(EditError::InvalidRange.into());
90 }90 }
91 let id = onestore::page::text::new_guid()?;
92 self.leave_title(engine)?;91 self.leave_title(engine)?;
92 if let Some(id) = self.across(engine, |editor, engine| {
93 editor.start_recording(engine, label)
94 })? {
95 return Ok(id);
96 }
97 let id = onestore::page::text::new_guid()?;
93 let [anchor, focus] = self.selection().positions;98 let [anchor, focus] = self.selection().positions;
94 let (start, end) = (anchor.min(focus), anchor.max(focus));99 let (start, end) = (anchor.min(focus), anchor.max(focus));
95 let mut format = self.typing_format(start)?;100 let mut format = self.typing_format(start)?;
crates/canvas/src/editor/table.rs+27-9
...@@ -35,16 +35,24 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {...@@ -35,16 +35,24 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {
35 None35 None
36}36}
3737
38/// A new cell like `source`, one empty paragraph in its first text's format; a cell holding
39/// no text, only pictures or files, gives a plain one.
38fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {40fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
39 let (_, _, paragraph) = leaves(&source.paragraphs, None)41 let node = match leaves(&source.paragraphs, None).next() {
40 .next()42 Some((_, _, paragraph)) => {
41 .ok_or(EditError::UnsupportedContent)?;43 let format = paragraph.text().unwrap().text.format_at(0)?;
42 let format = paragraph.text().unwrap().text.format_at(0)?;44 let mut node = crate::document::node(
43 let mut node = crate::document::node(45 Paragraph::new(String::new(), format.clone()),
44 Paragraph::new(String::new(), format.clone()),46 paragraph.format.clone(),
45 paragraph.format.clone(),47 )?;
46 )?;48 node.style = paragraph.style;
47 node.style = paragraph.style;49 node
50 }
51 None => crate::document::node(
52 Paragraph::new(String::new(), Default::default()),
53 Default::default(),
54 )?,
55 };
48 Ok(TableCell {56 Ok(TableCell {
49 id: new_id()?,57 id: new_id()?,
50 layout: Default::default(),58 layout: Default::default(),
...@@ -377,6 +385,16 @@ impl CanvasEditor {...@@ -377,6 +385,16 @@ impl CanvasEditor {
377 )385 )
378 }386 }
379 pub fn enter(&mut self, engine: &mut TextEngine, soft: bool) -> Result<(), EditorError> {387 pub fn enter(&mut self, engine: &mut TextEngine, soft: bool) -> Result<(), EditorError> {
388 let [anchor, focus] = self.active_outline().selection.positions;
389 // Inside a link's label OneNote 2010 ignores Shift+Enter, and Enter follows the link
390 // (`PageView` asks the host), so neither breaks it.
391 if anchor == focus
392 && self.link_at(focus).is_some_and(|link| {
393 link.label.start < focus.offset && focus.offset < link.label.end
394 })
395 {
396 return Ok(());
397 }
380 if soft {398 if soft {
381 return self.insert(engine, "\u{000b}");399 return self.insert(engine, "\u{000b}");
382 }400 }
crates/canvas/src/gpu/mod.rs+2
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1pub mod page;1pub mod page;
2#[cfg(test)]2#[cfg(test)]
3mod profile;3mod profile;
4mod tag_art;
5pub use tag_art::{TagArt, art_sources, import as import_tag_art};
46
5use crate::{7use crate::{
6 layout::{TextBrush, TextLayout},8 layout::{TextBrush, TextLayout},
crates/canvas/src/gpu/page.rs+161-48
...@@ -98,7 +98,8 @@ fn outline_origin(...@@ -98,7 +98,8 @@ fn outline_origin(
9898
99/// Draws `ink` moved by `offset`, strokes without a colour in the paper's `automatic` ink. A99/// 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 each100/// 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.101/// segment, its colour multiplying what lies beneath as OneNote's MaskPen does. A pressure
102/// pen's width follows its pressure from point to point, as OneNote's does.
102pub(crate) fn append_ink(103pub(crate) fn append_ink(
103 ink: &onestore::page::Ink,104 ink: &onestore::page::Ink,
104 offset: [f32; 2],105 offset: [f32; 2],
...@@ -112,12 +113,21 @@ pub(crate) fn append_ink(...@@ -112,12 +113,21 @@ pub(crate) fn append_ink(
112 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;113 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;
113 }114 }
114 let round = stroke.pen_tip != Some(1);115 let round = stroke.pen_tip != Some(1);
115 let segment = |from: [f32; 2], to: [f32; 2]| {116 let size = stroke.width.max(stroke.height);
117 let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| {
116 if !highlighter {118 if !highlighter {
119 if round && t0 != t1 {
120 return Primitive::Taper {
121 from,
122 to,
123 widths: [size * t0, size * t1],
124 color,
125 };
126 }
117 return Primitive::Segment {127 return Primitive::Segment {
118 from,128 from,
119 to,129 to,
120 width: stroke.width.max(stroke.height),130 width: size * (t0 + t1) / 2.0,
121 round,131 round,
122 color,132 color,
123 };133 };
...@@ -132,28 +142,30 @@ pub(crate) fn append_ink(...@@ -132,28 +142,30 @@ pub(crate) fn append_ink(
132 Primitive::Highlight {142 Primitive::Highlight {
133 from,143 from,
134 to,144 to,
135 width: stroke.width * across + stroke.height * along,145 width: (stroke.width * across + stroke.height * along) * (t0 + t1) / 2.0,
136 color,146 color,
137 }147 }
138 };148 };
139 let mut points = stroke149 let mut points = stroke
140 .points150 .points
141 .iter()151 .iter()
142 .map(|[x, y]| [x + offset[0], y + offset[1]]);152 .enumerate()
143 let Some(mut from) = points.next() else {153 .map(|(index, [x, y])| ([x + offset[0], y + offset[1]], stroke.thickness(index)));
154 let Some((mut from, mut thick)) = points.next() else {
144 continue;155 continue;
145 };156 };
146 // Samples along a straight run make one segment, since each joint paints its overlap157 // Samples along a straight run of one width make one segment, since each joint paints
147 // twice.158 // its overlap twice.
148 let mut end = from;159 let (mut end, mut end_thick) = (from, thick);
149 for to in points {160 for (to, to_thick) in points {
150 if !between(from, end, to) {161 let same = end == from || (to_thick == end_thick && end_thick == thick);
151 primitives.push(segment(from, end));162 if !(same && between(from, end, to)) {
152 from = end;163 primitives.push(segment(from, end, [thick, end_thick]));
164 (from, thick) = (end, end_thick);
153 }165 }
154 end = to;166 (end, end_thick) = (to, to_thick);
155 }167 }
156 primitives.push(segment(from, end));168 primitives.push(segment(from, end, [thick, end_thick]));
157 }169 }
158 for group in &ink.groups {170 for group in &ink.groups {
159 append_ink(group, offset, automatic, primitives);171 append_ink(group, offset, automatic, primitives);
...@@ -326,21 +338,18 @@ impl PageScene {...@@ -326,21 +338,18 @@ impl PageScene {
326 }338 }
327339
328 /// Every picture the page draws, page-level ones and those inside outlines, except the340 /// Every picture the page draws, page-level ones and those inside outlines, except the
329 /// template backgrounds it recognises; one the renderer cannot decode is left out.341 /// template backgrounds it recognises; one the renderer cannot decode is left out. A
342 /// picture shows its stored bytes, else the raster OneNote made of them.
330 fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> {343 fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> {
331 fn nested<'a>(344 type Payloads<'a> = Vec<(onestore::ExGuid, [Option<&'a Arc<[u8]>>; 2])>;
332 nodes: &'a [onestore::page::PageParagraph],345 fn nested<'a>(nodes: &'a [onestore::page::PageParagraph], payloads: &mut Payloads<'a>) {
333 payloads: &mut Vec<(onestore::ExGuid, Option<&'a Arc<[u8]>>)>,
334 ) {
335 for node in nodes {346 for node in nodes {
336 match &node.content {347 match &node.content {
337 onestore::page::ParagraphContent::Image(image) => {348 onestore::page::ParagraphContent::Image(image) => {
338 payloads.push((image.id, image.bytes.as_ref()))349 payloads.push((image.id, [image.bytes.as_ref(), image.display.as_ref()]))
339 }350 }
340 onestore::page::ParagraphContent::Attachment(file) => {351 onestore::page::ParagraphContent::Attachment(file) => {
341 if let Some(icon) = file.preview.as_ref() {352 payloads.push((file.id, [file.preview.as_ref(), None]))
342 payloads.push((file.id, Some(icon)))
343 }
344 }353 }
345 onestore::page::ParagraphContent::Table(table) => {354 onestore::page::ParagraphContent::Table(table) => {
346 for cell in table.rows.iter().flat_map(|row| &row.cells) {355 for cell in table.rows.iter().flat_map(|row| &row.cells) {
...@@ -361,13 +370,11 @@ impl PageScene {...@@ -361,13 +370,11 @@ impl PageScene {
361 {370 {
362 backgrounds.insert(source.id, art);371 backgrounds.insert(source.id, art);
363 } else {372 } else {
364 payloads.push((source.id, source.bytes.as_ref()))373 payloads.push((source.id, [source.bytes.as_ref(), source.display.as_ref()]))
365 }374 }
366 }375 }
367 Content::File { source, .. } => {376 Content::File { source, .. } => {
368 if let Some(icon) = source.preview.as_ref() {377 payloads.push((source.id, [source.preview.as_ref(), None]))
369 payloads.push((source.id, Some(icon)))
370 }
371 }378 }
372 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),379 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
373 Content::Editable(id) => {380 Content::Editable(id) => {
...@@ -382,7 +389,7 @@ impl PageScene {...@@ -382,7 +389,7 @@ impl PageScene {
382 }389 }
383 let mut pictures = std::collections::BTreeMap::new();390 let mut pictures = std::collections::BTreeMap::new();
384 for (id, encoded) in payloads {391 for (id, encoded) in payloads {
385 let Some(picture) = encoded.and_then(Picture::new) else {392 let Some(picture) = encoded.into_iter().flatten().find_map(Picture::new) else {
386 continue;393 continue;
387 };394 };
388 if pictures.insert(id, picture).is_some() {395 if pictures.insert(id, picture).is_some() {
...@@ -772,6 +779,7 @@ impl PageScene {...@@ -772,6 +779,7 @@ impl PageScene {
772 }779 }
773780
774 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.781 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
782 /// Outlines the editor does not hold draw their tags' symbols.
775 pub fn append_primitives_with<'a, E: From<SceneError>>(783 pub fn append_primitives_with<'a, E: From<SceneError>>(
776 &'a self,784 &'a self,
777 primitives: &mut Vec<Primitive<'a>>,785 primitives: &mut Vec<Primitive<'a>>,
...@@ -904,23 +912,31 @@ impl PageScene {...@@ -904,23 +912,31 @@ impl PageScene {
904 let Some(image) = image else {912 let Some(image) = image else {
905 continue;913 continue;
906 };914 };
907 primitives.push(Primitive::Image {915 let rect = match moving {
908 image,916 Some((id, [x0, y0, x1, y1])) if id == source.id => [
909 rect: match moving {917 x0 + offset[0],
910 Some((id, [x0, y0, x1, y1])) if id == source.id => [918 y0 + offset[1],
911 x0 + offset[0],919 x1 + offset[0],
912 y0 + offset[1],920 y1 + offset[1],
913 x1 + offset[0],921 ],
914 y1 + offset[1],922 _ => [
915 ],923 object_origin[0],
916 _ => [924 object_origin[1],
917 object_origin[0],925 object_origin[0] + width,
918 object_origin[1],926 object_origin[1] + height,
919 object_origin[0] + width,927 ],
920 object_origin[1] + height,928 };
921 ],929 primitives.push(Primitive::Image { image, rect });
922 },930 // OneNote frames a printout's page with a one-pixel grey line.
923 })931 if source.printout.is_some() {
932 let [x0, y0, x1, y1] = rect;
933 primitives.push(Primitive::RoundedRect {
934 rect: [x0 + 0.375, y0 + 0.375, x1 - 0.375, y1 - 0.375],
935 radius: [0.0; 2],
936 stroke: Some(draw::Stroke::Solid(0.75)),
937 color: paper.shade(colorref(0x00cc_cccc)),
938 });
939 }
924 }940 }
925 Content::Outline { .. } | Content::Date { .. } => {941 Content::Outline { .. } | Content::Date { .. } => {
926 let outline = match content {942 let outline = match content {
...@@ -931,6 +947,8 @@ impl PageScene {...@@ -931,6 +947,8 @@ impl PageScene {
931 .layout(),947 .layout(),
932 };948 };
933 outline.append_table_primitives(primitives, object_origin, paper);949 outline.append_table_primitives(primitives, object_origin, paper);
950 let art = super::TagArt::default();
951 outline.append_block_tag_primitives(primitives, object_origin, &art);
934 let everything = [f32::NEG_INFINITY, f32::INFINITY];952 let everything = [f32::NEG_INFINITY, f32::INFINITY];
935 outline.append_background_primitives(953 outline.append_background_primitives(
936 primitives,954 primitives,
...@@ -945,6 +963,7 @@ impl PageScene {...@@ -945,6 +963,7 @@ impl PageScene {
945 paragraph,963 paragraph,
946 object_origin,964 object_origin,
947 paper.ink,965 paper.ink,
966 &art,
948 primitives,967 primitives,
949 );968 );
950 }969 }
...@@ -1040,13 +1059,14 @@ impl crate::outline::OutlineLayout {...@@ -1040,13 +1059,14 @@ impl crate::outline::OutlineLayout {
1040 }1059 }
10411060
1042 /// One paragraph of this outline, whose origin is `origin`: its text or equation, list1061 /// One paragraph of this outline, whose origin is `origin`: its text or equation, list
1043 /// markers and tags.1062 /// markers and tags, those `art` maps drawn with their art.
1044 pub fn append_paragraph_primitives<'a>(1063 pub fn append_paragraph_primitives<'a>(
1045 &'a self,1064 &'a self,
1046 index: usize,1065 index: usize,
1047 paragraph: &'a crate::outline::ParagraphLayout,1066 paragraph: &'a crate::outline::ParagraphLayout,
1048 origin: [f32; 2],1067 origin: [f32; 2],
1049 ink: [f32; 4],1068 ink: [f32; 4],
1069 art: &super::TagArt,
1050 primitives: &mut Vec<Primitive<'a>>,1070 primitives: &mut Vec<Primitive<'a>>,
1051 ) {1071 ) {
1052 let [x, y] = [1072 let [x, y] = [
...@@ -1117,7 +1137,7 @@ impl crate::outline::OutlineLayout {...@@ -1117,7 +1137,7 @@ impl crate::outline::OutlineLayout {
1117 }1137 }
1118 for tag in &paragraph.tags {1138 for tag in &paragraph.tags {
1119 primitives.push(Primitive::Icon {1139 primitives.push(Primitive::Icon {
1120 sources: super::tag_sources(tag.icon),1140 sources: art.sources(tag),
1121 origin: [1141 origin: [
1122 origin[0] + self.tag_column_offset() + tag.origin[0],1142 origin[0] + self.tag_column_offset() + tag.origin[0],
1123 y + tag.origin[1],1143 y + tag.origin[1],
...@@ -1129,6 +1149,33 @@ impl crate::outline::OutlineLayout {...@@ -1129,6 +1149,33 @@ impl crate::outline::OutlineLayout {
1129 }1149 }
1130 }1150 }
11311151
1152 /// The note tags of this outline's tables, pictures and files, whose origin is `origin`,
1153 /// those `art` maps drawn with their art.
1154 pub fn append_block_tag_primitives(
1155 &self,
1156 primitives: &mut Vec<Primitive<'_>>,
1157 origin: [f32; 2],
1158 art: &super::TagArt,
1159 ) {
1160 let tables = self.tables.iter().filter_map(|table| table.tags.as_ref());
1161 let objects = self
1162 .objects
1163 .iter()
1164 .filter_map(|object| object.tags.as_ref());
1165 for tag in tables.chain(objects).flat_map(|block| &block.tags) {
1166 primitives.push(Primitive::Icon {
1167 sources: art.sources(tag),
1168 origin: [
1169 origin[0] + self.tag_column_offset() + tag.origin[0],
1170 origin[1] + tag.origin[1],
1171 ],
1172 size: tag.size,
1173 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
1174 palette: draw::Palette::default(),
1175 });
1176 }
1177 }
1178
1132 pub fn append_table_primitives(1179 pub fn append_table_primitives(
1133 &self,1180 &self,
1134 primitives: &mut Vec<Primitive<'_>>,1181 primitives: &mut Vec<Primitive<'_>>,
...@@ -1577,6 +1624,7 @@ mod tests {...@@ -1577,6 +1624,7 @@ mod tests {
1577 size: None,1624 size: None,
1578 id: onestore::page::text::new_id().unwrap(),1625 id: onestore::page::text::new_id().unwrap(),
1579 bytes: Some(image_bytes.clone()),1626 bytes: Some(image_bytes.clone()),
1627 display: None,
1580 layout: Layout {1628 layout: Layout {
1581 x: Some(x),1629 x: Some(x),
1582 y: Some(y),1630 y: Some(y),
...@@ -1586,6 +1634,7 @@ mod tests {...@@ -1586,6 +1634,7 @@ mod tests {
1586 },1634 },
1587 alt: None,1635 alt: None,
1588 background,1636 background,
1637 printout: None,
1589 }));1638 }));
1590 }1639 }
1591 let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();1640 let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
...@@ -1944,8 +1993,10 @@ mod tests {...@@ -1944,8 +1993,10 @@ mod tests {
1944 ..Default::default()1993 ..Default::default()
1945 },1994 },
1946 bytes: None,1995 bytes: None,
1996 display: None,
1947 alt: Some("missing diagram".into()),1997 alt: Some("missing diagram".into()),
1948 background: false,1998 background: false,
1999 printout: None,
1949 }),2000 }),
1950 PageObject::Unsupported(unknown.clone()),2001 PageObject::Unsupported(unknown.clone()),
1951 ],2002 ],
...@@ -2112,8 +2163,10 @@ mod tests {...@@ -2112,8 +2163,10 @@ mod tests {
2112 ..Default::default()2163 ..Default::default()
2113 },2164 },
2114 bytes: Some(Arc::from(bytes.clone())),2165 bytes: Some(Arc::from(bytes.clone())),
2166 display: None,
2115 alt: None,2167 alt: None,
2116 background: false,2168 background: false,
2169 printout: None,
2117 }),2170 }),
2118 outline(50.0, "last"),2171 outline(50.0, "last"),
2119 ],2172 ],
...@@ -2254,6 +2307,7 @@ mod tests {...@@ -2254,6 +2307,7 @@ mod tests {
2254 transparency: Some(51),2307 transparency: Some(51),
2255 pen_tip: None,2308 pen_tip: None,
2256 raster_operation: None,2309 raster_operation: None,
2310 pressure: Vec::new(),
2257 };2311 };
2258 let ink = Ink {2312 let ink = Ink {
2259 id: ExGuid::default(),2313 id: ExGuid::default(),
...@@ -2317,6 +2371,59 @@ mod tests {...@@ -2317,6 +2371,59 @@ mod tests {
2317 assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]);2371 assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]);
2318 }2372 }
23192373
2374 #[test]
2375 fn pressure_widens_a_stroke_from_point_to_point_as_onenote_draws_it() {
2376 use onestore::page::{Ink, InkStroke};
2377 let stroke = |points: Vec<[f32; 2]>, pressure: Vec<f32>| InkStroke {
2378 id: ExGuid::default(),
2379 points,
2380 width: 4.0,
2381 height: 4.0,
2382 color: None,
2383 transparency: None,
2384 pen_tip: None,
2385 raster_operation: None,
2386 pressure,
2387 };
2388 let ink = Ink {
2389 id: ExGuid::default(),
2390 layout: Layout::default(),
2391 strokes: vec![
2392 stroke(
2393 vec![[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]],
2394 vec![0.0, 0.5, 1.0],
2395 ),
2396 // A straight run at one pressure is one segment.
2397 stroke(vec![[0.0, 10.0], [10.0, 10.0], [20.0, 10.0]], vec![0.0; 3]),
2398 ],
2399 groups: Vec::new(),
2400 shape: None,
2401 };
2402 let mut primitives = Vec::new();
2403 append_ink(&ink, [0.0; 2], [0.0, 0.0, 0.0, 1.0], &mut primitives);
2404 let found: Vec<([f32; 2], [f32; 2], [f32; 2])> = primitives
2405 .iter()
2406 .map(|primitive| match primitive {
2407 Primitive::Taper {
2408 from, to, widths, ..
2409 } => (*from, *to, *widths),
2410 Primitive::Segment {
2411 from, to, width, ..
2412 } => (*from, *to, [*width; 2]),
2413 _ => panic!(),
2414 })
2415 .collect();
2416 // A quarter of the pen at no pressure, all of it at half and 1.75 times at full.
2417 assert_eq!(
2418 found,
2419 [
2420 ([0.0, 0.0], [10.0, 0.0], [1.0, 4.0]),
2421 ([10.0, 0.0], [20.0, 0.0], [4.0, 7.0]),
2422 ([0.0, 10.0], [20.0, 10.0], [1.0, 1.0]),
2423 ]
2424 );
2425 }
2426
2320 #[test]2427 #[test]
2321 fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() {2428 fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() {
2322 let mut encoded = Vec::new();2429 let mut encoded = Vec::new();
...@@ -2349,8 +2456,10 @@ mod tests {...@@ -2349,8 +2456,10 @@ mod tests {
2349 ..Default::default()2456 ..Default::default()
2350 },2457 },
2351 bytes: Some(Arc::from(encoded.clone())),2458 bytes: Some(Arc::from(encoded.clone())),
2459 display: None,
2352 alt: None,2460 alt: None,
2353 background: false,2461 background: false,
2462 printout: None,
2354 })],2463 })],
2355 };2464 };
2356 let mut engine = TextEngine::default();2465 let mut engine = TextEngine::default();
...@@ -2412,8 +2521,10 @@ mod tests {...@@ -2412,8 +2521,10 @@ mod tests {
2412 ..Default::default()2521 ..Default::default()
2413 },2522 },
2414 bytes: Some(Arc::from(bytes.clone())),2523 bytes: Some(Arc::from(bytes.clone())),
2524 display: None,
2415 alt: None,2525 alt: None,
2416 background,2526 background,
2527 printout: None,
2417 };2528 };
2418 let [background, picture] = [image(0.0, true), image(50.0, false)];2529 let [background, picture] = [image(0.0, true), image(50.0, false)];
2419 let id = picture.id;2530 let id = picture.id;
...@@ -2481,8 +2592,10 @@ mod tests {...@@ -2481,8 +2592,10 @@ mod tests {
2481 ..Default::default()2592 ..Default::default()
2482 },2593 },
2483 bytes: Some(Arc::clone(&bytes)),2594 bytes: Some(Arc::clone(&bytes)),
2595 display: None,
2484 alt: None,2596 alt: None,
2485 background: false,2597 background: false,
2598 printout: None,
2486 })2599 })
2487 .collect();2600 .collect();
2488 assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES);2601 assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES);
crates/canvas/src/gpu/page/background.rs+2
...@@ -753,8 +753,10 @@ mod tests {...@@ -753,8 +753,10 @@ mod tests {
753 },753 },
754 size: None,754 size: None,
755 bytes: Some(Arc::from(bytes)),755 bytes: Some(Arc::from(bytes)),
756 display: None,
756 alt: None,757 alt: None,
757 background: true,758 background: true,
759 printout: None,
758 }760 }
759 }761 }
760762
crates/canvas/src/gpu/profile.rs+2
...@@ -243,8 +243,10 @@ fn frame_cost() {...@@ -243,8 +243,10 @@ fn frame_cost() {
243 },243 },
244 size: None,244 size: None,
245 bytes: None,245 bytes: None,
246 display: None,
246 alt: None,247 alt: None,
247 background: false,248 background: false,
249 printout: None,
248 });250 });
249 let cell = || TableCell {251 let cell = || TableCell {
250 id: new_id().unwrap(),252 id: new_id().unwrap(),
crates/canvas/src/gpu/tag_art.rs created+230
...@@ -0,0 +1,230 @@
1//! Pictures Snowbound draws for tags in place of their symbols. A notebook maps a tag, by
2//! the name and symbol its definition stores, to art of its own; OneNote 2010, which knows
3//! nothing of the art, draws the symbol.
4
5use super::{CHECKMARK, tag_sources};
6use crate::outline::{ParagraphTag, TagIcon};
7use resvg::{tiny_skia, usvg};
8use std::{collections::BTreeMap, sync::Mutex};
9
10/// The longest side art is drawn from, in pixels: a 24 pt tag at 200% on a 2× display.
11const SIDE: u32 = 128;
12
13/// Icon sources drawing a tag's art, unchecked and checked.
14type Sources = [&'static [&'static str]; 2];
15
16/// A picked PNG or SVG prepared for keeping as tag art, with its extension: an SVG as it is,
17/// a PNG no larger than the art is drawn from. None where it is neither, or draws nothing.
18pub fn import(bytes: &[u8]) -> Option<(Vec<u8>, &'static str)> {
19 let drawn = drawn(bytes)?;
20 Some(match svg(bytes) {
21 Some(_) => (bytes.to_vec(), "svg"),
22 None => (drawn, "png"),
23 })
24}
25
26/// The sources drawing art named `art`, made from what `bytes` reads the first time `art`
27/// is asked for; none where that is neither a PNG nor an SVG. Art is named by its content,
28/// so what one name draws never changes.
29pub fn art_sources(art: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Option<Sources> {
30 static MADE: Mutex<BTreeMap<String, Option<Sources>>> = Mutex::new(BTreeMap::new());
31 let mut made = MADE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
32 if let Some(made) = made.get(art) {
33 return *made;
34 }
35 let sources = bytes()
36 .and_then(|bytes| drawn(&bytes))
37 .and_then(|png| draw::picture_icon(&png))
38 .map(|source| {
39 // Each art's sources are made once and drawn for the program's life.
40 let source: &'static str = source.leak();
41 let plain: &'static [&'static str] = Box::leak(Box::new([source]));
42 let checked: &'static [&'static str] = Box::leak(Box::new([source, CHECKMARK]));
43 [plain, checked]
44 });
45 made.insert(art.to_owned(), sources);
46 sources
47}
48
49/// Art as a PNG no larger than `SIDE`: an SVG drawn, a PNG scaled down; none where it is
50/// neither, or draws nothing.
51fn drawn(bytes: &[u8]) -> Option<Vec<u8>> {
52 if let Some(tree) = svg(bytes) {
53 return raster(&tree);
54 }
55 let picture = image::load_from_memory_with_format(bytes, image::ImageFormat::Png).ok()?;
56 let [width, height] = fit([picture.width(), picture.height()]);
57 let picture = picture.resize_exact(width, height, image::imageops::FilterType::Lanczos3);
58 Some(png(&picture.into_rgba8()))
59}
60
61/// An SVG document, reading no file it names.
62fn svg(bytes: &[u8]) -> Option<usvg::Tree> {
63 let mut options = usvg::Options::default();
64 options.image_href_resolver.resolve_string = Box::new(|_, _| None);
65 usvg::Tree::from_data(bytes, &options).ok()
66}
67
68/// `tree` drawn as a PNG at its proportions no larger than `SIDE`; none where it paints
69/// nothing.
70fn raster(tree: &usvg::Tree) -> Option<Vec<u8>> {
71 let view = tree.size();
72 let [width, height] = fit([view.width(), view.height()].map(|side| side.ceil() as u32));
73 let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
74 resvg::render(
75 tree,
76 tiny_skia::Transform::from_scale(
77 width as f32 / view.width(),
78 height as f32 / view.height(),
79 ),
80 &mut pixmap.as_mut(),
81 );
82 let rgba: Vec<u8> = pixmap
83 .pixels()
84 .iter()
85 .flat_map(|pixel| {
86 let color = pixel.demultiply();
87 [color.red(), color.green(), color.blue(), color.alpha()]
88 })
89 .collect();
90 if rgba.chunks_exact(4).all(|pixel| pixel[3] == 0) {
91 return None;
92 }
93 Some(png(&image::RgbaImage::from_raw(width, height, rgba)?))
94}
95
96/// A picture of `size` scaled to fit `SIDE` at its proportions, never larger.
97fn fit(size: [u32; 2]) -> [u32; 2] {
98 let longest = size[0].max(size[1]).max(1);
99 if longest <= SIDE {
100 return size.map(|side| side.max(1));
101 }
102 size.map(|side| (u64::from(side) * u64::from(SIDE) / u64::from(longest)).max(1) as u32)
103}
104
105fn png(picture: &image::RgbaImage) -> Vec<u8> {
106 let mut encoded = Vec::new();
107 let mut encoder = png::Encoder::new(&mut encoded, picture.width(), picture.height());
108 encoder.set_color(png::ColorType::Rgba);
109 encoder.set_depth(png::BitDepth::Eight);
110 encoder
111 .write_header()
112 .and_then(|mut writer| writer.write_image_data(picture.as_raw()))
113 .expect("A PNG encodes into memory");
114 encoded
115}
116
117/// The art a notebook's tags draw with, by the name and symbol their definitions store.
118#[derive(Clone, Default)]
119pub struct TagArt(Vec<Mapped>);
120
121#[derive(Clone)]
122struct Mapped {
123 name: String,
124 shape: u16,
125 art: String,
126 sources: Sources,
127}
128
129impl TagArt {
130 /// Draws tags named `name` with symbol `shape` with art `art`, drawn by `sources`, in
131 /// place of any art they drew with before.
132 pub fn map(&mut self, name: &str, shape: u16, art: &str, sources: Sources) {
133 self.0
134 .retain(|mapped| (mapped.name.as_str(), mapped.shape) != (name, shape));
135 self.0.push(Mapped {
136 name: name.to_owned(),
137 shape,
138 art: art.to_owned(),
139 sources,
140 });
141 }
142
143 /// The art tags named `name` with symbol `shape` draw with.
144 pub fn art(&self, name: &str, shape: u16) -> Option<&str> {
145 self.find(name, shape).map(|mapped| mapped.art.as_str())
146 }
147
148 /// What draws `tag`: its art, or its symbol.
149 pub fn sources(&self, tag: &ParagraphTag) -> &'static [&'static str] {
150 match tag.icon {
151 TagIcon::Symbol { shape, checked } => self.find(&tag.label, shape).map_or_else(
152 || tag_sources(tag.icon),
153 |mapped| mapped.sources[usize::from(checked)],
154 ),
155 TagIcon::Task { .. } => tag_sources(tag.icon),
156 }
157 }
158
159 fn find(&self, name: &str, shape: u16) -> Option<&Mapped> {
160 self.0
161 .iter()
162 .find(|mapped| mapped.name == name && mapped.shape == shape)
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn encoded(width: u32, height: u32) -> Vec<u8> {
171 png(&image::RgbaImage::from_pixel(
172 width,
173 height,
174 image::Rgba([200, 40, 40, 255]),
175 ))
176 }
177
178 #[test]
179 fn pictures_keep_their_proportions_within_the_largest_side() {
180 let (kept, extension) = import(&encoded(400, 100)).unwrap();
181 assert_eq!(extension, "png");
182 let picture = image::load_from_memory(&kept).unwrap();
183 assert_eq!([picture.width(), picture.height()], [SIDE, SIDE / 4]);
184 let small = encoded(20, 30);
185 let (kept, _) = import(&small).unwrap();
186 let picture = image::load_from_memory(&kept).unwrap();
187 assert_eq!([picture.width(), picture.height()], [20, 30]);
188 assert!(import(b"neither").is_none());
189 }
190
191 #[test]
192 fn drawings_are_kept_as_written_unless_they_draw_nothing() {
193 let drawing = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="#3070f0"/></svg>"##;
194 assert_eq!(import(drawing), Some((drawing.to_vec(), "svg")));
195 let empty = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#;
196 assert!(import(empty).is_none());
197 // A drawing naming a file on this computer reads none of it.
198 let named = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="/etc/hosts" width="10" height="10"/></svg>"#;
199 assert!(import(named).is_none());
200 }
201
202 #[test]
203 fn mapped_tags_draw_their_art_checked_or_not() {
204 let art = art_sources("test-art", || Some(encoded(8, 8))).unwrap();
205 // Made once: a second ask reads nothing.
206 assert_eq!(art_sources("test-art", || unreachable!()), Some(art));
207 assert_eq!(art_sources("test-broken", || Some(b"x".to_vec())), None);
208 let mut map = TagArt::default();
209 map.map("Launch", 13, "test-art", art);
210 let tag = |label: &str, shape, checked| ParagraphTag {
211 icon: TagIcon::Symbol { shape, checked },
212 origin: [0.0; 2],
213 size: ParagraphTag::SIZE,
214 label: label.into(),
215 disabled: false,
216 };
217 assert_eq!(map.sources(&tag("Launch", 13, false)), art[0]);
218 assert_eq!(map.sources(&tag("Launch", 13, true)), art[1]);
219 assert_eq!(art[1].last(), Some(&CHECKMARK));
220 let symbol = TagIcon::of(13, false).unwrap();
221 assert_eq!(
222 map.sources(&tag("Launch", 15, false)),
223 tag_sources(TagIcon::of(15, false).unwrap())
224 );
225 assert_eq!(map.sources(&tag("Other", 13, false)), tag_sources(symbol));
226 assert_eq!(map.art("Launch", 13), Some("test-art"));
227 map.map("Launch", 13, "test-other", art);
228 assert_eq!(map.art("Launch", 13), Some("test-other"));
229 }
230}
crates/canvas/src/interaction/accessibility.rs+3-3
...@@ -118,7 +118,7 @@ impl Accessibility {...@@ -118,7 +118,7 @@ impl Accessibility {
118 editor: &CanvasEditor,118 editor: &CanvasEditor,
119 scene: Option<&(PageScene, [f32; 2])>,119 scene: Option<&(PageScene, [f32; 2])>,
120 viewport: Viewport,120 viewport: Viewport,
121 title: &str,121 label: &str,
122 preview: Option<(ExGuid, [f32; 2])>,122 preview: Option<(ExGuid, [f32; 2])>,
123 read_only_focus: Option<usize>,123 read_only_focus: Option<usize>,
124 ) -> Result<TreeUpdate, EditError> {124 ) -> Result<TreeUpdate, EditError> {
...@@ -331,8 +331,8 @@ impl Accessibility {...@@ -331,8 +331,8 @@ impl Accessibility {
331 f64::from(viewport.origin[1]),331 f64::from(viewport.origin[1]),
332 ]));332 ]));
333 page.set_children(children);333 page.set_children(children);
334 let mut root = Node::new(Role::Window);334 let mut root = Node::new(Role::Group);
335 root.set_label(title);335 root.set_label(label);
336 root.set_children(vec![PAGE]);336 root.set_children(vec![PAGE]);
337 root.set_bounds(Rect::new(337 root.set_bounds(Rect::new(
338 0.0,338 0.0,
crates/canvas/src/interaction/ink.rs+24-9
...@@ -47,10 +47,13 @@ pub(super) struct State {...@@ -47,10 +47,13 @@ pub(super) struct State {
47 gesture: Option<Gesture>,47 gesture: Option<Gesture>,
48 /// Drawings picked by the lasso or a click.48 /// Drawings picked by the lasso or a click.
49 selection: Vec<ExGuid>,49 selection: Vec<ExGuid>,
50 /// The pen's pressure where the pointer is, from 0 to 1; none for a mouse or finger.
51 pressure: Option<f32>,
50}52}
5153
52enum Gesture {54enum Gesture {
53 Stroke(Vec<[f32; 2]>),55 /// The points drawn through, and the pressure at each where the pen reports it.
56 Stroke(Vec<[f32; 2]>, Vec<f32>),
54 Shape([f32; 2]),57 Shape([f32; 2]),
55 /// `mark` counts the ops waiting when the sweep began.58 /// `mark` counts the ops waiting when the sweep began.
56 Erase {59 Erase {
...@@ -80,6 +83,12 @@ impl PageView {...@@ -80,6 +83,12 @@ impl PageView {
80 Response::redraw()83 Response::redraw()
81 }84 }
8285
86 /// The pen's pressure at the pointer, from 0 to 1, for what the pointer does next: none
87 /// for a mouse or finger, whose strokes keep their pen's width.
88 pub fn set_pressure(&mut self, pressure: Option<f32>) {
89 self.ink.pressure = pressure;
90 }
91
83 /// Drawings the lasso or a click picked.92 /// Drawings the lasso or a click picked.
84 pub fn ink_selection(&self) -> &[ExGuid] {93 pub fn ink_selection(&self) -> &[ExGuid] {
85 &self.ink.selection94 &self.ink.selection
...@@ -154,7 +163,7 @@ impl PageView {...@@ -154,7 +163,7 @@ impl PageView {
154 self.ink.selection = vec![id];163 self.ink.selection = vec![id];
155 Gesture::Move { press: point }164 Gesture::Move { press: point }
156 }165 }
157 Tool::Pen(_) => Gesture::Stroke(vec![point]),166 Tool::Pen(_) => Gesture::Stroke(vec![point], self.ink.pressure.into_iter().collect()),
158 Tool::Shape(..) => Gesture::Shape(self.grid(point)),167 Tool::Shape(..) => Gesture::Shape(self.grid(point)),
159 Tool::Eraser => {168 Tool::Eraser => {
160 let mark = self.editor.pending_ops();169 let mark = self.editor.pending_ops();
...@@ -185,12 +194,18 @@ impl PageView {...@@ -185,12 +194,18 @@ impl PageView {
185194
186 /// The pointer moved to page point `point` during a gesture.195 /// The pointer moved to page point `point` during a gesture.
187 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {196 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {
197 let pressure = self.ink.pressure;
188 match &mut self.ink.gesture {198 match &mut self.ink.gesture {
189 Some(Gesture::Stroke(points) | Gesture::Lasso(points)) => {199 Some(Gesture::Stroke(points, _) | Gesture::Lasso(points)) => {
190 let last = points[points.len() - 1];200 let last = points[points.len() - 1];
191 // Samples closer than a HIMETRIC unit store as one point.201 // Samples closer than a HIMETRIC unit store as one point.
192 if (point[0] - last[0]).hypot(point[1] - last[1]) >= 72.0 / 2540.0 {202 if (point[0] - last[0]).hypot(point[1] - last[1]) >= 72.0 / 2540.0 {
193 points.push(point);203 points.push(point);
204 if let Some(Gesture::Stroke(_, levels)) = &mut self.ink.gesture
205 && let Some(&last) = levels.last()
206 {
207 levels.push(pressure.unwrap_or(last));
208 }
194 }209 }
195 Ok(Response::redraw())210 Ok(Response::redraw())
196 }211 }
...@@ -222,14 +237,14 @@ impl PageView {...@@ -222,14 +237,14 @@ impl PageView {
222 return Ok(Response::default());237 return Ok(Response::default());
223 };238 };
224 match gesture {239 match gesture {
225 Gesture::Stroke(points) => {240 Gesture::Stroke(points, pressure) => {
226 let Tool::Pen(pen) = self.ink.tool else {241 let Tool::Pen(pen) = self.ink.tool else {
227 return Ok(Response::default());242 return Ok(Response::default());
228 };243 };
229 self.editor.draw(Ink {244 self.editor.draw(Ink {
230 id: new_id()?,245 id: new_id()?,
231 layout: Default::default(),246 layout: Default::default(),
232 strokes: vec![pen.stroke(&points)?],247 strokes: vec![pen.stroke(&points, &pressure)?],
233 groups: Vec::new(),248 groups: Vec::new(),
234 shape: None,249 shape: None,
235 })?;250 })?;
...@@ -243,7 +258,7 @@ impl PageView {...@@ -243,7 +258,7 @@ impl PageView {
243 return Ok(Response::redraw());258 return Ok(Response::redraw());
244 }259 }
245 self.editor260 self.editor
246 .draw(Ink::drawn(kind, from, to, &pen.stroke(&[])?)?)?;261 .draw(Ink::drawn(kind, from, to, &pen.stroke(&[], &[])?)?)?;
247 }262 }
248 Gesture::Erase { .. } => return Ok(Response::redraw()),263 Gesture::Erase { .. } => return Ok(Response::redraw()),
249 Gesture::Lasso(mut points) => {264 Gesture::Lasso(mut points) => {
...@@ -312,14 +327,14 @@ impl PageView {...@@ -312,14 +327,14 @@ impl PageView {
312 crate::gpu::page::append_ink(&ink, [0.0; 2], automatic, primitives)327 crate::gpu::page::append_ink(&ink, [0.0; 2], automatic, primitives)
313 };328 };
314 match (&self.ink.gesture, self.ink.tool) {329 match (&self.ink.gesture, self.ink.tool) {
315 (Some(Gesture::Stroke(points)), Tool::Pen(pen)) => {330 (Some(Gesture::Stroke(points, pressure)), Tool::Pen(pen)) => {
316 if let Ok(stroke) = pen.stroke(points) {331 if let Ok(stroke) = pen.stroke(points, pressure) {
317 preview(ink(vec![stroke]), primitives);332 preview(ink(vec![stroke]), primitives);
318 }333 }
319 }334 }
320 (Some(Gesture::Shape(from)), Tool::Shape(kind, pen)) => {335 (Some(Gesture::Shape(from)), Tool::Shape(kind, pen)) => {
321 let to = self.grid(point);336 let to = self.grid(point);
322 if let Ok(pen) = pen.stroke(&[])337 if let Ok(pen) = pen.stroke(&[], &[])
323 && let Ok(shape) = Ink::drawn(kind, *from, to, &pen)338 && let Ok(shape) = Ink::drawn(kind, *from, to, &pen)
324 {339 {
325 preview(shape, primitives);340 preview(shape, primitives);
crates/canvas/src/interaction/mod.rs+350-47
...@@ -84,6 +84,21 @@ pub struct Context {...@@ -84,6 +84,21 @@ pub struct Context {
84 pub paragraph: Option<onestore::ExGuid>,84 pub paragraph: Option<onestore::ExGuid>,
85 /// The file the press selected, which Open and Save As take.85 /// The file the press selected, which Open and Save As take.
86 pub attachment: Option<onestore::page::Attachment>,86 pub attachment: Option<onestore::page::Attachment>,
87 /// The marked word the press landed on, which the menu offers corrections for.
88 pub spelling: Option<Correction>,
89}
90
91/// A marked word under a context menu, and what it could become.
92#[derive(Debug, PartialEq)]
93pub struct Correction {
94 pub word: String,
95 /// Replacements, best first; none for a repeated word, which Delete Repeated Word
96 /// removes with the space before it.
97 pub suggestions: Vec<String>,
98 pub repeated: bool,
99 outline: onestore::ExGuid,
100 /// What a correction replaces: the word, or a repeated word and the space before it.
101 range: Selection,
87}102}
88103
89/// What an event did: `changed` means the page or selection changed (the host saves,104/// What an event did: `changed` means the page or selection changed (the host saves,
...@@ -136,6 +151,8 @@ pub struct TextColors {...@@ -136,6 +151,8 @@ pub struct TextColors {
136struct Paint<'a> {151struct Paint<'a> {
137 caret: f32,152 caret: f32,
138 scale: f32,153 scale: f32,
154 /// The device pixel the document origin lands on, for marks drawn on the pixel grid.
155 device_origin: [f32; 2],
139 pixel: f32,156 pixel: f32,
140 colors: TextColors,157 colors: TextColors,
141 /// The document's top and bottom the view shows; paragraphs wholly outside it are158 /// The document's top and bottom the view shows; paragraphs wholly outside it are
...@@ -147,6 +164,8 @@ struct Paint<'a> {...@@ -147,6 +164,8 @@ struct Paint<'a> {
147 found: &'a [crate::search::PageMatch],164 found: &'a [crate::search::PageMatch],
148 /// The note See Playback highlights.165 /// The note See Playback highlights.
149 played: Option<&'a crate::search::PageMatch>,166 played: Option<&'a crate::search::PageMatch>,
167 spelling: Option<&'a crate::spelling::Spelling>,
168 tag_art: &'a crate::gpu::TagArt,
150}169}
151170
152enum Drag {171enum Drag {
...@@ -242,7 +261,11 @@ pub struct PageView {...@@ -242,7 +261,11 @@ pub struct PageView {
242 /// Matches of the search shown on the page, marked under their text.261 /// Matches of the search shown on the page, marked under their text.
243 pub found: Vec<crate::search::PageMatch>,262 pub found: Vec<crate::search::PageMatch>,
244 /// The note playing, highlighted as See Playback highlights it.263 /// The note playing, highlighted as See Playback highlights it.
245 pub played: Option<crate::search::PageMatch>,264 played: Option<crate::search::PageMatch>,
265 /// Marks misspelled and repeated words; none leaves words unmarked.
266 pub spelling: Option<crate::spelling::Spelling>,
267 /// The art the page's notebook draws its tags with.
268 pub tag_art: std::sync::Arc<crate::gpu::TagArt>,
246 /// The caret's opacity in its blink.269 /// The caret's opacity in its blink.
247 caret: f32,270 caret: f32,
248 /// When the caret last moved, which restarts its blink.271 /// When the caret last moved, which restarts its blink.
...@@ -305,6 +328,8 @@ impl PageView {...@@ -305,6 +328,8 @@ impl PageView {
305 host_viewport: false,328 host_viewport: false,
306 found: Vec::new(),329 found: Vec::new(),
307 played: None,330 played: None,
331 spelling: None,
332 tag_art: Default::default(),
308 caret: 1.0,333 caret: 1.0,
309 blink_from: Instant::now(),334 blink_from: Instant::now(),
310 ink: Default::default(),335 ink: Default::default(),
...@@ -537,17 +562,15 @@ impl PageView {...@@ -537,17 +562,15 @@ impl PageView {
537 let shaped = outline.shaped();562 let shaped = outline.shaped();
538 let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset();563 let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset();
539 let top = offset[1] + outline.origin()[1];564 let top = offset[1] + outline.origin()[1];
540 shaped.paragraphs.iter().find_map(|paragraph| {565 shaped.tags().find_map(|(_, origin, tag)| {
541 paragraph.tags.iter().find_map(|tag| {566 let x0 = left + origin[0];
542 let x0 = left + tag.origin[0];567 let y0 = top + origin[1];
543 let y0 = top + paragraph.origin[1] + tag.origin[1];568 (matches!(tag.icon, crate::outline::TagIcon::Task { .. })
544 (matches!(tag.icon, crate::outline::TagIcon::Task { .. })569 && (x0..=x0 + tag.size).contains(&x)
545 && (x0..=x0 + tag.size).contains(&x)570 && (y0..=y0 + tag.size).contains(&y))
546 && (y0..=y0 + tag.size).contains(&y))571 .then(|| {
547 .then(|| {572 let [x1, y1] = [x0 + tag.size, y0 + tag.size];
548 let [x1, y1] = [x0 + tag.size, y0 + tag.size];573 [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)]
549 [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)]
550 })
551 })574 })
552 })575 })
553 })576 })
...@@ -643,23 +666,33 @@ impl PageView {...@@ -643,23 +666,33 @@ impl PageView {
643 rect.y1 += f64::from(origin[1]);666 rect.y1 += f64::from(origin[1]);
644 rect667 rect
645 };668 };
646 let outline = self.editor.active_outline().bounds();669 let outline = self
670 .object_focus
671 .is_none()
672 .then(|| self.editor.active_outline().bounds());
673 self.reveal(rect, outline);
674 Ok(())
675 }
676
677 /// Scrolls document `rect` into view, and with it all of `outline` where that fits.
678 fn reveal(&mut self, rect: parley::BoundingBox, outline: Option<parley::BoundingBox>) {
647 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]679 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]
648 .into_iter()680 .into_iter()
649 .enumerate()681 .enumerate()
650 {682 {
651 let size = f64::from(self.viewport.size[axis]);683 let size = f64::from(self.viewport.size[axis]);
652 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);684 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);
653 let (outline_start, outline_end) =685 if let Some(outline) = outline {
654 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];686 let (outline_start, outline_end) =
655 let outline_start = outline_start.min(start);687 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
656 let outline_end = outline_end.max(end);688 let outline_start = outline_start.min(start);
657 if self.object_focus.is_none()689 let outline_end = outline_end.max(end);
658 && (outline_end - outline_start) * f64::from(self.viewport.scale)690 if (outline_end - outline_start) * f64::from(self.viewport.scale)
659 <= size - margin * 2.0691 <= size - margin * 2.0
660 {692 {
661 start = outline_start;693 start = outline_start;
662 end = outline_end;694 end = outline_end;
695 }
663 }696 }
664 let start =697 let start =
665 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);698 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
...@@ -673,7 +706,45 @@ impl PageView {...@@ -673,7 +706,45 @@ impl PageView {
673 };706 };
674 self.viewport.origin[axis] += shift as f32;707 self.viewport.origin[axis] += shift as f32;
675 }708 }
676 Ok(())709 }
710
711 /// Highlights `played`, the note playing, as See Playback does, scrolling it into view
712 /// as OneNote does when the note changes.
713 pub fn set_played(&mut self, played: Option<crate::search::PageMatch>) -> Result<Response> {
714 if played == self.played {
715 return Ok(Response::default());
716 }
717 self.played = played;
718 let outline = played.and_then(|(id, _)| {
719 self.editor
720 .outlines()
721 .iter()
722 .find(|outline| outline.id == id)
723 });
724 if let (Some(outline), Some((_, selection))) = (outline, played)
725 && !self.host_viewport
726 && !self.viewport.size.contains(&0)
727 {
728 let [x, y] = outline.origin().map(f64::from);
729 let rect = outline
730 .range_rects(selection)?
731 .into_iter()
732 .reduce(|a, b| a.union(b))
733 .map(|rect| parley::BoundingBox {
734 x0: rect.x0 + x,
735 y0: rect.y0 + y,
736 x1: rect.x1 + x,
737 y1: rect.y1 + y,
738 });
739 if let Some(rect) = rect {
740 self.reveal(rect, None);
741 return self.moved();
742 }
743 }
744 Ok(Response {
745 redraw: true,
746 ..Response::default()
747 })
677 }748 }
678749
679 /// Moves the caret by lines until it has gone a view's height, then scrolls as far so750 /// Moves the caret by lines until it has gone a view's height, then scrolls as far so
...@@ -868,6 +939,7 @@ impl PageView {...@@ -868,6 +939,7 @@ impl PageView {
868 0.0939 0.0
869 },940 },
870 scale: self.viewport.scale,941 scale: self.viewport.scale,
942 device_origin: self.viewport.origin,
871 pixel: self.pixel(),943 pixel: self.pixel(),
872 colors,944 colors,
873 visible: [0.0, self.viewport.size[1] as f32]945 visible: [0.0, self.viewport.size[1] as f32]
...@@ -875,6 +947,8 @@ impl PageView {...@@ -875,6 +947,8 @@ impl PageView {
875 chrome: !self.touch || self.focused,947 chrome: !self.touch || self.focused,
876 found: &self.found,948 found: &self.found,
877 played: self.played.as_ref(),949 played: self.played.as_ref(),
950 spelling: self.spelling.as_ref(),
951 tag_art: &self.tag_art,
878 },952 },
879 )?);953 )?);
880 if self.drag.is_none()954 if self.drag.is_none()
...@@ -1429,12 +1503,125 @@ impl PageView {...@@ -1429,12 +1503,125 @@ impl PageView {
1429 .leaf(anchor.min(focus).paragraph)1503 .leaf(anchor.min(focus).paragraph)
1430 .map(|(_, _, node)| node.id),1504 .map(|(_, _, node)| node.id),
1431 attachment: None,1505 attachment: None,
1506 spelling: self.selected_correction(),
1432 };1507 };
1433 Ok(Some((self.changed()?, context)))1508 Ok(Some((self.changed()?, context)))
1434 }1509 }
14351510
1436 /// The text and address the Link dialog opens with for the selection.1511 /// The marked word at the caret, or wholly selected, with its corrections.
1437 pub fn link_prefill(&self) -> (String, String) {1512 pub fn selected_correction(&self) -> Option<Correction> {
1513 let [anchor, focus] = self.editor.selection().positions;
1514 let correction = self.correction(anchor.min(focus))?;
1515 (anchor == focus || self.correction(anchor.max(focus)).as_ref() == Some(&correction))
1516 .then_some(correction)
1517 }
1518
1519 /// The marked word at `at` in the focused outline, with its corrections.
1520 fn correction(&self, at: crate::document::TextPosition) -> Option<Correction> {
1521 let spelling = self.spelling.as_ref()?;
1522 let outline = self.editor.active_outline();
1523 let paragraph = outline.document().paragraph(at.paragraph)?;
1524 let byte = paragraph.byte_offset(at.offset).ok()?;
1525 let mark = spelling
1526 .marks(paragraph)
1527 .into_iter()
1528 .find(|mark| mark.range.start <= byte && byte <= mark.range.end)?;
1529 correction(spelling, outline, at.paragraph, &mark)
1530 }
1531
1532 /// The Spelling pane's next word: the first marked word after the selection in page
1533 /// order, wrapping round to the top, selected. None once the page marks no word.
1534 pub fn next_correction(&mut self) -> Result<Option<(Response, Correction)>> {
1535 let Some(spelling) = &self.spelling else {
1536 return Ok(None);
1537 };
1538 let (id, word, correction) = {
1539 let mut outlines: Vec<&TextOutline> = self.editor.outlines().iter().collect();
1540 outlines.sort_by(|a, b| {
1541 let [ax, ay] = a.origin();
1542 let [bx, by] = b.origin();
1543 ay.total_cmp(&by).then(ax.total_cmp(&bx))
1544 });
1545 let active = self.editor.active_outline().id;
1546 let active = outlines.iter().position(|outline| outline.id == active);
1547 let [anchor, focus] = self.editor.selection().positions;
1548 let after = anchor.max(focus);
1549 let mut marked = outlines.iter().enumerate().flat_map(|(order, outline)| {
1550 outline.layouts().flat_map(move |(index, _)| {
1551 let paragraph = outline.document().paragraph(index);
1552 paragraph
1553 .map(|paragraph| spelling.marks_now(paragraph))
1554 .unwrap_or_default()
1555 .into_iter()
1556 .map(move |mark| (order, *outline, index, mark))
1557 })
1558 });
1559 let mut first = None;
1560 // The first word not wholly before the selection; the page's first after them all.
1561 let found = marked.find(|(order, outline, index, mark)| {
1562 first.get_or_insert_with(|| (*order, *outline, *index, mark.clone()));
1563 let Some(active) = active else {
1564 return true;
1565 };
1566 let paragraph = outline.document().paragraph(*index);
1567 let offset = |byte| {
1568 paragraph.map_or(0, |paragraph| paragraph.utf16_offset(byte).unwrap_or(0))
1569 };
1570 let caret = (after.paragraph, after.offset);
1571 *order > active
1572 || *order == active
1573 && ((*index, offset(mark.range.end)) > caret
1574 || (*index, offset(mark.range.start)) >= caret)
1575 });
1576 let Some((_, outline, index, mark)) = found.or(first) else {
1577 return Ok(None);
1578 };
1579 let correction = correction(spelling, outline, index, &mark).ok_or("Unmarked word")?;
1580 let paragraph = outline
1581 .document()
1582 .paragraph(index)
1583 .ok_or("Missing paragraph")?;
1584 let at = |byte| -> Result<crate::document::TextPosition> {
1585 Ok(crate::document::TextPosition {
1586 paragraph: index,
1587 offset: paragraph.utf16_offset(byte)?,
1588 })
1589 };
1590 let word: Selection = [at(mark.range.start)?, at(mark.range.end)?].into();
1591 (outline.id, word, correction)
1592 };
1593 self.editor.focus_outline(id)?;
1594 self.editor.select(word)?;
1595 Ok(Some((self.edited()?, correction)))
1596 }
1597
1598 /// Replaces the word `correction` names with `text`, or with nothing, as one edit; not
1599 /// once an edit since has changed the word.
1600 pub fn correct(&mut self, correction: &Correction, text: &str) -> Result<Response> {
1601 let [start, end] = correction.range.positions;
1602 let unchanged = self
1603 .editor
1604 .outlines()
1605 .iter()
1606 .find(|outline| outline.id == correction.outline)
1607 .and_then(|outline| outline.document().paragraph(start.paragraph))
1608 .and_then(|paragraph| {
1609 let range = paragraph.byte_offset(start.offset).ok()?
1610 ..paragraph.byte_offset(end.offset).ok()?;
1611 Some(paragraph.text().get(range)?.trim_start() == correction.word)
1612 });
1613 if unchanged != Some(true) {
1614 return Ok(Response::default());
1615 }
1616 self.editor.focus_outline(correction.outline)?;
1617 self.editor.select(correction.range)?;
1618 self.editor.correct(&mut self.engine, text)?;
1619 self.edited()
1620 }
1621
1622 /// The text and address the Link dialog opens with for the selection; none where it does
1623 /// not open.
1624 pub fn link_prefill(&self) -> Option<(String, String)> {
1438 self.editor.link_prefill()1625 self.editor.link_prefill()
1439 }1626 }
14401627
...@@ -1489,8 +1676,10 @@ impl PageView {...@@ -1489,8 +1676,10 @@ impl PageView {
1489 },1676 },
1490 size: Some(size),1677 size: Some(size),
1491 bytes: Some(bytes.into()),1678 bytes: Some(bytes.into()),
1679 display: None,
1492 alt: None,1680 alt: None,
1493 background: false,1681 background: false,
1682 printout: None,
1494 };1683 };
1495 self.editor.insert_picture(&mut self.engine, image)?;1684 self.editor.insert_picture(&mut self.engine, image)?;
1496 self.edited()1685 self.edited()
...@@ -1995,19 +2184,17 @@ fn page_hit(...@@ -1995,19 +2184,17 @@ fn page_hit(
1995 }2184 }
1996 let shaped = outline.shaped();2185 let shaped = outline.shaped();
1997 let [left, top] = outline.origin();2186 let [left, top] = outline.origin();
1998 let check = shaped.paragraphs.iter().find(|paragraph| {2187 let check = shaped.tags().find(|(_, origin, tag)| {
1999 paragraph.tags.iter().any(|tag| {2188 let tag_x = left + shaped.tag_column_offset() + origin[0];
2000 let tag_x = left + shaped.tag_column_offset() + tag.origin[0];2189 let tag_y = top + origin[1];
2001 let tag_y = top + paragraph.origin[1] + tag.origin[1];2190 tag.icon.checkable()
2002 tag.icon.checkable()2191 && (tag_x..=tag_x + tag.size).contains(&x)
2003 && (tag_x..=tag_x + tag.size).contains(&x)2192 && (tag_y..=tag_y + tag.size).contains(&y)
2004 && (tag_y..=tag_y + tag.size).contains(&y)
2005 })
2006 });2193 });
2007 if let Some(paragraph) = check {2194 if let Some((paragraph, ..)) = check {
2008 return Some(Hit::Check {2195 return Some(Hit::Check {
2009 outline: outline.id,2196 outline: outline.id,
2010 paragraph: paragraph.id,2197 paragraph,
2011 });2198 });
2012 }2199 }
2013 let text = outline.bounds();2200 let text = outline.bounds();
...@@ -2435,14 +2622,13 @@ fn reach(outline: &TextOutline) -> [f32; 2] {...@@ -2435,14 +2622,13 @@ fn reach(outline: &TextOutline) -> [f32; 2] {
2435 .iter()2622 .iter()
2436 .flat_map(|paragraph| {2623 .flat_map(|paragraph| {
2437 let markers = paragraph.markers.iter().map(|(_, [x, _])| *x);2624 let markers = paragraph.markers.iter().map(|(_, [x, _])| *x);
2438 let tags = paragraph2625 std::iter::once(paragraph.origin[0] - 7.5).chain(markers)
2439 .tags
2440 .iter()
2441 .map(move |tag| tag.origin[0] + column + 0.75);
2442 std::iter::once(paragraph.origin[0] - 7.5)
2443 .chain(markers)
2444 .chain(tags)
2445 })2626 })
2627 .chain(
2628 shaped
2629 .tags()
2630 .map(|(_, origin, _)| origin[0] + column + 0.75),
2631 )
2446 .fold(-7.5, f32::min);2632 .fold(-7.5, f32::min);
2447 let [x, y] = outline.origin();2633 let [x, y] = outline.origin();
2448 [x + left, y - 6.0]2634 [x + left, y - 6.0]
...@@ -2492,11 +2678,8 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {...@@ -2492,11 +2678,8 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
2492 let left = shaped2678 let left = shaped
2493 .paragraphs2679 .paragraphs
2494 .iter()2680 .iter()
2495 .flat_map(|paragraph| {2681 .flat_map(|paragraph| paragraph.markers.iter().map(|(_, [x, _])| x + 7.5))
2496 let tags = paragraph.tags.iter().map(|tag| tag.origin[0] + column);2682 .chain(shaped.tags().map(|(_, origin, _)| origin[0] + column))
2497 let markers = paragraph.markers.iter().map(|(_, [x, _])| x + 7.5);
2498 tags.chain(markers)
2499 })
2500 .fold(0.0, f32::min);2683 .fold(0.0, f32::min);
2501 (2684 (
2502 [2685 [
...@@ -2608,17 +2791,22 @@ fn append_outline<'a>(...@@ -2608,17 +2791,22 @@ fn append_outline<'a>(
2608 let Paint {2791 let Paint {
2609 caret,2792 caret,
2610 scale,2793 scale,
2794 device_origin,
2611 pixel,2795 pixel,
2612 colors,2796 colors,
2613 visible,2797 visible,
2614 found,2798 found,
2615 played,2799 played,
2800 spelling,
2616 ..2801 ..
2617 } = paint;2802 } = paint;
2618 let rows = [visible[0] - y, visible[1] - y];2803 let rows = [visible[0] - y, visible[1] - y];
2619 outline2804 outline
2620 .shaped()2805 .shaped()
2621 .append_table_primitives(primitives, origin, colors.paper);2806 .append_table_primitives(primitives, origin, colors.paper);
2807 outline
2808 .shaped()
2809 .append_block_tag_primitives(primitives, origin, paint.tag_art);
2622 outline2810 outline
2623 .shaped()2811 .shaped()
2624 .append_background_primitives(primitives, origin, rows, colors.paper);2812 .append_background_primitives(primitives, origin, rows, colors.paper);
...@@ -2668,9 +2856,26 @@ fn append_outline<'a>(...@@ -2668,9 +2856,26 @@ fn append_outline<'a>(
2668 paragraph,2856 paragraph,
2669 origin,2857 origin,
2670 colors.paper.ink,2858 colors.paper.ink,
2859 paint.tag_art,
2671 primitives,2860 primitives,
2672 );2861 );
2673 }2862 }
2863 if let Some(spelling) = spelling {
2864 let typing = editor
2865 .and_then(CanvasEditor::typing)
2866 .filter(|(id, _)| *id == outline.id)
2867 .map(|(_, at)| at);
2868 append_marks(
2869 spelling,
2870 outline,
2871 typing,
2872 origin,
2873 rows,
2874 [scale, device_origin[0], device_origin[1]],
2875 pixel,
2876 primitives,
2877 )?;
2878 }
2674 if let Some(editor) = editor {2879 if let Some(editor) = editor {
2675 for rect in editor.marked_rects()? {2880 for rect in editor.marked_rects()? {
2676 primitives.push(Primitive::Rect {2881 primitives.push(Primitive::Rect {
...@@ -2704,3 +2909,101 @@ fn append_outline<'a>(...@@ -2704,3 +2909,101 @@ fn append_outline<'a>(
2704 }2909 }
2705 Ok(())2910 Ok(())
2706}2911}
2912
2913/// OneNote's red zigzag under each marked word of the paragraphs between outline-local
2914/// `rows`: a pixel thick, two high and four long, 1.5 to 3.5 pixels below the baseline, laid
2915/// on the device grid `[scale, x, y]` so it stays hard-edged. The word ending at the caret of
2916/// a run of typing, `typing`, is still being written and goes unmarked.
2917#[allow(clippy::too_many_arguments)]
2918fn append_marks(
2919 spelling: &crate::spelling::Spelling,
2920 outline: &TextOutline,
2921 typing: Option<crate::document::TextPosition>,
2922 origin: [f32; 2],
2923 rows: [f32; 2],
2924 [scale, device_x, device_y]: [f32; 3],
2925 pixel: f32,
2926 primitives: &mut Vec<Primitive<'_>>,
2927) -> Result<()> {
2928 let color = draw::srgb(0xff, 0x00, 0x00);
2929 let dot = (pixel * scale).round().max(1.0);
2930 let shown = outline.layouts().filter(|(_, paragraph)| {
2931 paragraph.origin[1] + paragraph.text.height() >= rows[0] && paragraph.origin[1] <= rows[1]
2932 });
2933 for (index, _) in shown {
2934 let Some(paragraph) = outline.document().paragraph(index) else {
2935 continue;
2936 };
2937 for mark in spelling.marks(paragraph) {
2938 let start = paragraph.utf16_offset(mark.range.start)?;
2939 let end = paragraph.utf16_offset(mark.range.end)?;
2940 if typing.is_some_and(|at| at.paragraph == index && at.offset == end) {
2941 continue;
2942 }
2943 for [left, right, baseline] in outline.underlines(index, start..end)? {
2944 let right = ((origin[0] + right) * scale + device_x).round();
2945 let mut x = ((origin[0] + left) * scale + device_x).round();
2946 let top = ((origin[1] + baseline) * scale + device_y).round() + dot;
2947 for step in [1.0, 2.0, 1.0, 0.0].into_iter().cycle() {
2948 if x >= right {
2949 break;
2950 }
2951 let y = top + step * dot;
2952 primitives.push(Primitive::Rect {
2953 rect: [
2954 (x - device_x) / scale,
2955 (y - device_y) / scale,
2956 (x + dot - device_x) / scale,
2957 (y + dot - device_y) / scale,
2958 ],
2959 color,
2960 });
2961 x += dot;
2962 }
2963 }
2964 }
2965 }
2966 Ok(())
2967}
2968
2969/// Marked word `mark` of paragraph `index` of `outline`, with its corrections.
2970fn correction(
2971 spelling: &crate::spelling::Spelling,
2972 outline: &TextOutline,
2973 index: usize,
2974 mark: &crate::spelling::Mark,
2975) -> Option<Correction> {
2976 let paragraph = outline.document().paragraph(index)?;
2977 let text = paragraph.text();
2978 let word = text[mark.range.clone()].to_owned();
2979 let start = if mark.repeated {
2980 text[..mark.range.start].trim_end().len()
2981 } else {
2982 mark.range.start
2983 };
2984 let position = |byte| {
2985 Some(crate::document::TextPosition {
2986 paragraph: index,
2987 offset: paragraph.utf16_offset(byte).ok()?,
2988 })
2989 };
2990 let suggestions = if mark.repeated {
2991 Vec::new()
2992 } else {
2993 let language = paragraph
2994 .spans()
2995 .iter()
2996 .find(|span| span.end > mark.range.start)
2997 .and_then(|span| span.format.language);
2998 let mut suggestions = spelling.suggest(&word, language);
2999 suggestions.truncate(5);
3000 suggestions
3001 };
3002 Some(Correction {
3003 suggestions,
3004 repeated: mark.repeated,
3005 outline: outline.id,
3006 range: [position(start)?, position(mark.range.end)?].into(),
3007 word,
3008 })
3009}
crates/canvas/src/interaction/tests.rs+250
...@@ -14,17 +14,22 @@ const COLORS: TextColors = TextColors {...@@ -14,17 +14,22 @@ const COLORS: TextColors = TextColors {
14 paper: crate::gpu::Paper::WHITE,14 paper: crate::gpu::Paper::WHITE,
15};15};
1616
17static NO_ART: std::sync::LazyLock<crate::gpu::TagArt> = std::sync::LazyLock::new(Default::default);
18
17/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.19/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.
18pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> {20pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> {
19 Paint {21 Paint {
20 caret: f32::from(u8::from(show_caret)),22 caret: f32::from(u8::from(show_caret)),
21 scale,23 scale,
24 device_origin: [0.0; 2],
22 pixel: display_scale / scale,25 pixel: display_scale / scale,
23 colors: COLORS,26 colors: COLORS,
24 visible: [f32::NEG_INFINITY, f32::INFINITY],27 visible: [f32::NEG_INFINITY, f32::INFINITY],
25 chrome: true,28 chrome: true,
26 found: &[],29 found: &[],
27 played: None,30 played: None,
31 spelling: None,
32 tag_art: &NO_ART,
28 }33 }
29}34}
3035
...@@ -699,8 +704,10 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {...@@ -699,8 +704,10 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
699 id,704 id,
700 layout: Default::default(),705 layout: Default::default(),
701 bytes: None,706 bytes: None,
707 display: None,
702 alt: None,708 alt: None,
703 background: false,709 background: false,
710 printout: None,
704 });711 });
705 source.paragraphs.insert(1, picture);712 source.paragraphs.insert(1, picture);
706 let editor = CanvasEditor::from_page(713 let editor = CanvasEditor::from_page(
...@@ -885,12 +892,15 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -885,12 +892,15 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
885 Paint {892 Paint {
886 caret: 0.0,893 caret: 0.0,
887 scale: 1.0,894 scale: 1.0,
895 device_origin: [0.0; 2],
888 pixel: 1.0,896 pixel: 1.0,
889 colors: COLORS,897 colors: COLORS,
890 visible: [f32::NEG_INFINITY, f32::INFINITY],898 visible: [f32::NEG_INFINITY, f32::INFINITY],
891 chrome: true,899 chrome: true,
892 found: &[],900 found: &[],
893 played: None,901 played: None,
902 spelling: None,
903 tag_art: &NO_ART,
894 },904 },
895 &mut primitives,905 &mut primitives,
896 )906 )
...@@ -960,12 +970,15 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -960,12 +970,15 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
960 Paint {970 Paint {
961 caret: 0.0,971 caret: 0.0,
962 scale: 1.0,972 scale: 1.0,
973 device_origin: [0.0; 2],
963 pixel: 1.0,974 pixel: 1.0,
964 colors: COLORS,975 colors: COLORS,
965 visible: [f32::NEG_INFINITY, f32::INFINITY],976 visible: [f32::NEG_INFINITY, f32::INFINITY],
966 chrome: true,977 chrome: true,
967 found: &[],978 found: &[],
968 played: None,979 played: None,
980 spelling: None,
981 tag_art: &NO_ART,
969 },982 },
970 &mut primitives,983 &mut primitives,
971 )984 )
...@@ -1528,8 +1541,10 @@ fn picture_view() -> (PageView, onestore::ExGuid) {...@@ -1528,8 +1541,10 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1528 id,1541 id,
1529 layout: Default::default(),1542 layout: Default::default(),
1530 bytes: None,1543 bytes: None,
1544 display: None,
1531 alt: None,1545 alt: None,
1532 background: false,1546 background: false,
1547 printout: None,
1533 });1548 });
1534 source.paragraphs.insert(1, picture);1549 source.paragraphs.insert(1, picture);
1535 let editor = CanvasEditor::from_page(1550 let editor = CanvasEditor::from_page(
...@@ -2885,3 +2900,238 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {...@@ -2885,3 +2900,238 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
2885 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));2900 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
2886 view.primitives(COLORS).unwrap();2901 view.primitives(COLORS).unwrap();
2887}2902}
2903
2904/// Words the fake dictionary misses are marked red under their text once checked, except
2905/// the word still being typed; the context menu offers the dictionary's corrections, and
2906/// taking one is one edit and one undo step.
2907#[test]
2908fn misspelled_words_are_marked_and_corrected_in_one_edit() {
2909 struct Wake(std::sync::mpsc::Sender<()>);
2910 impl std::task::Wake for Wake {
2911 fn wake(self: Arc<Self>) {
2912 let _ = self.0.send(());
2913 }
2914 }
2915 let mut engine = TextEngine::default();
2916 let document = TextDocument::new(vec![Paragraph::new(
2917 "Ths sentence has the typos".into(),
2918 Default::default(),
2919 )])
2920 .unwrap();
2921 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
2922 let editor = CanvasEditor::from_page(
2923 Page {
2924 title: String::new(),
2925 identity: None,
2926 created: None,
2927 margin_origin: [36.0, 14.4],
2928 color: None,
2929 rule_lines: None,
2930 definitions: Default::default(),
2931 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
2932 },
2933 &mut engine,
2934 )
2935 .unwrap();
2936 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
2937 view.viewport.scale = 1.0;
2938 view.viewport.origin = [0.0; 2];
2939 let (woken, wakes) = std::sync::mpsc::channel();
2940 view.spelling = Some(crate::spelling::Spelling::new(
2941 Box::new(crate::spelling::tests::Fake),
2942 Arc::new(Wake(woken)).into(),
2943 ));
2944 let red = draw::srgb(0xff, 0x00, 0x00);
2945 let marks = |view: &PageView| -> Vec<[f32; 2]> {
2946 view.primitives(COLORS)
2947 .unwrap()
2948 .into_iter()
2949 .filter_map(|primitive| match primitive {
2950 Primitive::Rect { rect, color } if color == red => Some([rect[0], rect[1]]),
2951 _ => None,
2952 })
2953 .collect()
2954 };
2955 assert!(marks(&view).is_empty());
2956 wakes
2957 .recv_timeout(Duration::from_secs(10))
2958 .expect("the spelling thread wakes the host");
2959 let outline = view.editor.active_outline();
2960 let [origin_x, origin_y] = outline.origin();
2961 let [left, right, baseline] = outline.underlines(0, 0..3).unwrap()[0];
2962 let under = marks(&view);
2963 // OneNote's pixels along "Ths": a column each, stepping mid, low, mid, high on the
2964 // three pixel rows from one to three below the baseline.
2965 let [start, end, line] =
2966 [origin_x + left, origin_x + right, origin_y + baseline].map(f32::round);
2967 assert_eq!(under.len(), (end - start) as usize);
2968 for (column, [x, y]) in under.into_iter().enumerate() {
2969 assert_eq!(x, start + column as f32);
2970 assert_eq!(y, line + [2.0, 3.0, 2.0, 1.0][column % 4]);
2971 }
2972
2973 let _ = view
2974 .pointer_moved([origin_x + (left + right) / 2.0, origin_y + baseline - 3.0])
2975 .unwrap();
2976 let (_, context) = view.context().unwrap().unwrap();
2977 let correction = context.spelling.unwrap();
2978 assert_eq!(correction.word, "Ths");
2979 assert_eq!(correction.suggestions, ["This", "Thus"]);
2980 view.editor.take_ops().unwrap();
2981 assert!(view.correct(&correction, "This").unwrap().changed);
2982 assert_eq!(
2983 view.editor.active_outline().shown_text(),
2984 "This sentence has the typos"
2985 );
2986 assert!(!view.editor.take_ops().unwrap().is_empty());
2987 marks(&view);
2988 wakes
2989 .recv_timeout(Duration::from_secs(10))
2990 .expect("the spelling thread wakes the host");
2991 assert!(marks(&view).is_empty());
2992 let _ = view.undo(false).unwrap();
2993 assert_eq!(
2994 view.editor.active_outline().shown_text(),
2995 "Ths sentence has the typos"
2996 );
2997
2998 view.editor
2999 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3000 .unwrap();
3001 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3002 for letter in ["q", "w", "r", "t"] {
3003 let _ = view
3004 .key(&Key::Character(letter.into()), Some(letter))
3005 .unwrap();
3006 }
3007 marks(&view);
3008 wakes
3009 .recv_timeout(Duration::from_secs(10))
3010 .expect("the spelling thread wakes the host");
3011 let typing = marks(&view).len();
3012 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3013 marks(&view);
3014 wakes
3015 .recv_timeout(Duration::from_secs(10))
3016 .expect("the spelling thread wakes the host");
3017 assert!(marks(&view).len() > typing);
3018}
3019
3020/// See Playback scrolls the note playing into view, as OneNote 2010 does
3021/// (`corpus/recording/native/read/onenote-see-playback-scrolls.png`); the caret stays.
3022#[test]
3023fn the_note_playing_scrolls_into_view() {
3024 let mut engine = TextEngine::default();
3025 let lines = (0..100)
3026 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
3027 .collect();
3028 let outline = TextOutline::new(
3029 &mut engine,
3030 TextDocument::new(lines).unwrap(),
3031 240.0,
3032 [36.0, 36.0],
3033 )
3034 .unwrap();
3035 let editor = CanvasEditor::from_page(
3036 Page {
3037 title: String::new(),
3038 identity: None,
3039 created: None,
3040 margin_origin: [36.0, 14.4],
3041 color: None,
3042 rule_lines: None,
3043 definitions: Default::default(),
3044 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3045 },
3046 &mut engine,
3047 )
3048 .unwrap();
3049 let mut view = PageView::new(
3050 editor,
3051 engine,
3052 None,
3053 [800, 200],
3054 1.0,
3055 Duration::from_millis(500),
3056 );
3057 view.viewport.scale = 1.0;
3058 view.viewport.origin = [0.0; 2];
3059 let caret = view.editor.selection();
3060 let outline = view.editor.outlines()[0].id;
3061 let at = |offset| TextPosition {
3062 paragraph: 60,
3063 offset,
3064 };
3065 let note: Selection = [at(0), at(7)].into();
3066 let played = Some((outline, note));
3067 assert!(view.set_played(played).unwrap().moved);
3068 let rects = view.editor.outlines()[0].range_rects(note).unwrap();
3069 let top = view.editor.outlines()[0].origin()[1] + rects[0].y0 as f32;
3070 let bottom = view.editor.outlines()[0].origin()[1] + rects[0].y1 as f32;
3071 let shown = [top, bottom].map(|y| y + view.viewport.origin[1]);
3072 assert!(shown[0] >= 0.0 && shown[1] <= 200.0, "{shown:?}");
3073 assert_eq!(view.editor.selection(), caret);
3074 // Playing on within the same note leaves the view where the reader put it.
3075 view.viewport.origin[1] = 0.0;
3076 assert!(!view.set_played(played).unwrap().moved);
3077 assert_eq!(view.viewport.origin[1], 0.0);
3078}
3079
3080/// The Spelling pane walks marked words from the caret in page order, wraps to the page's
3081/// top, and ends once none is left.
3082#[test]
3083fn the_spelling_pane_walks_marked_words_from_the_caret() {
3084 let mut engine = TextEngine::default();
3085 let document = TextDocument::new(
3086 ["the wrng word", "this is qwrt here"]
3087 .map(|text| Paragraph::new(text.into(), Default::default()))
3088 .into(),
3089 )
3090 .unwrap();
3091 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
3092 let editor = CanvasEditor::from_page(
3093 Page {
3094 title: String::new(),
3095 identity: None,
3096 created: None,
3097 margin_origin: [36.0, 14.4],
3098 color: None,
3099 rule_lines: None,
3100 definitions: Default::default(),
3101 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3102 },
3103 &mut engine,
3104 )
3105 .unwrap();
3106 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
3107 let spelling = crate::spelling::Spelling::new(
3108 Box::new(crate::spelling::tests::Fake),
3109 std::task::Waker::noop().clone(),
3110 );
3111 view.spelling = Some(spelling.clone());
3112 let caret = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
3113 view.editor.select(caret(1, 17)).unwrap();
3114 let next = |view: &mut PageView| {
3115 view.next_correction()
3116 .unwrap()
3117 .map(|(_, correction)| correction.word)
3118 };
3119 assert_eq!(next(&mut view).as_deref(), Some("wrng"));
3120 assert_eq!(
3121 view.editor.selection().positions,
3122 [
3123 TextPosition {
3124 paragraph: 0,
3125 offset: 4
3126 },
3127 TextPosition {
3128 paragraph: 0,
3129 offset: 8
3130 }
3131 ]
3132 );
3133 spelling.ignore("wrng");
3134 assert_eq!(next(&mut view).as_deref(), Some("qwrt"));
3135 spelling.learn("qwrt");
3136 assert_eq!(next(&mut view), None);
3137}
crates/canvas/src/language.rs+13-1
...@@ -104,7 +104,7 @@ const LCIDS: &[(&str, u32)] = &[...@@ -104,7 +104,7 @@ const LCIDS: &[(&str, u32)] = &[
104];104];
105105
106/// en-US, what OneNote records when nothing better is known.106/// en-US, what OneNote records when nothing better is known.
107const EN_US: u32 = 1033;107pub(crate) const EN_US: u32 = 1033;
108108
109/// The Windows LCID of a BCP-47 tag such as an input source's language (`de`, `en-GB`,109/// The Windows LCID of a BCP-47 tag such as an input source's language (`de`, `en-GB`,
110/// `zh-Hans`), matched by region, then script, then language, else en-US.110/// `zh-Hans`), matched by region, then script, then language, else en-US.
...@@ -133,6 +133,15 @@ pub fn lcid(tag: &str) -> u32 {...@@ -133,6 +133,15 @@ pub fn lcid(tag: &str) -> u32 {
133 .unwrap_or(EN_US)133 .unwrap_or(EN_US)
134}134}
135135
136/// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks
137/// for it. None for LCIDs outside the table, such as math's.
138pub fn tag(lcid: u32) -> Option<&'static str> {
139 LCIDS
140 .iter()
141 .find(|(_, known)| *known == lcid)
142 .map(|(tag, _)| *tag)
143}
144
136#[cfg(test)]145#[cfg(test)]
137mod tests {146mod tests {
138 use super::*;147 use super::*;
...@@ -158,5 +167,8 @@ mod tests {...@@ -158,5 +167,8 @@ mod tests {
158 ] {167 ] {
159 assert_eq!(super::lcid(tag), lcid, "{tag}");168 assert_eq!(super::lcid(tag), lcid, "{tag}");
160 }169 }
170 for (lcid, tag) in [(1033, Some("en")), (2057, Some("en-GB")), (0x1007f, None)] {
171 assert_eq!(super::tag(lcid), tag);
172 }
161 }173 }
162}174}
crates/canvas/src/layout.rs+10
...@@ -638,6 +638,16 @@ impl TextLayout {...@@ -638,6 +638,16 @@ impl TextLayout {
638 rect638 rect
639 }639 }
640640
641 /// Each line's part of `selection` as a mark under the text runs: its left, its right
642 /// and the line's baseline.
643 pub fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> {
644 selection
645 .geometry(&self.shaped)
646 .into_iter()
647 .map(|(rect, index)| [rect.x0 as f32, rect.x1 as f32, self.lines[index].baseline])
648 .collect()
649 }
650
641 pub fn selection(&self, selection: Selection) -> Vec<BoundingBox> {651 pub fn selection(&self, selection: Selection) -> Vec<BoundingBox> {
642 selection652 selection
643 .geometry(&self.shaped)653 .geometry(&self.shaped)
crates/canvas/src/lib.rs+1
...@@ -6,6 +6,7 @@ pub mod layout;...@@ -6,6 +6,7 @@ pub mod layout;
6pub mod math;6pub mod math;
7pub mod outline;7pub mod outline;
8pub mod search;8pub mod search;
9pub mod spelling;
9pub mod template;10pub mod template;
1011
11pub mod date;12pub mod date;
crates/canvas/src/outline.rs+168-17
...@@ -66,6 +66,67 @@ pub struct ObjectLayout {...@@ -66,6 +66,67 @@ pub struct ObjectLayout {
66 pub kind: ObjectKind,66 pub kind: ObjectKind,
67 /// Outline-local bottom of the whole object, label included.67 /// Outline-local bottom of the whole object, label included.
68 pub bottom: f32,68 pub bottom: f32,
69 pub tags: Option<BlockTags>,
70}
71
72/// The note tags of a table, picture or file, which OneNote centres on it in the tag column.
73#[derive(Clone)]
74pub struct BlockTags {
75 /// The paragraph holding the block.
76 pub paragraph: ExGuid,
77 /// Its siblings' group, whose list markers the tags clear.
78 parent: Option<ExGuid>,
79 /// The block's left edge, outline-local.
80 x: f32,
81 /// Origins are outline-local.
82 pub tags: Vec<ParagraphTag>,
83}
84
85impl BlockTags {
86 /// `node`'s tags and those of its content, `content`, shaped as a tagged paragraph of
87 /// `node`'s format would show them; `None` without any.
88 fn new(
89 node: &PageParagraph,
90 content: &[onestore::document::Tag],
91 x: f32,
92 shape: &mut impl FnMut(
93 &PageParagraph,
94 Option<&Count>,
95 f32,
96 &[f32],
97 ) -> Result<ParagraphLayout, LayoutError>,
98 ) -> Result<Option<Self>, LayoutError> {
99 if node.tags.is_empty() && content.is_empty() {
100 return Ok(None);
101 }
102 let mut tagged = caption(node.id, node.id, "", node.format.clone());
103 tagged.tags.clone_from(&node.tags);
104 if let ParagraphContent::Text(text) = &mut tagged.content {
105 text.tags = content.to_vec();
106 }
107 let tags = shape(&tagged, None, ATTACHMENT_WIDTH, &[0.0, 0.0])?.tags;
108 Ok(Some(Self {
109 paragraph: node.id,
110 parent: node.parent,
111 x,
112 tags,
113 }))
114 }
115
116 /// Centres the tags on a block spanning `top..bottom`.
117 fn centre(&mut self, top: f32, bottom: f32) {
118 for tag in &mut self.tags {
119 tag.origin[1] = (top + bottom - tag.size) / 2.0;
120 }
121 }
122
123 fn offset(&mut self, [x, y]: [f32; 2]) {
124 self.x += x;
125 for tag in &mut self.tags {
126 tag.origin[0] += x;
127 tag.origin[1] += y;
128 }
129 }
69}130}
70131
71#[derive(Clone)]132#[derive(Clone)]
...@@ -121,6 +182,10 @@ impl ObjectLayout {...@@ -121,6 +182,10 @@ impl ObjectLayout {
121 if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut self.kind {182 if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut self.kind {
122 label.origin[1] = label_top;183 label.origin[1] = label_top;
123 }184 }
185 let [_, top, _, bottom] = self.bounds();
186 if let Some(tags) = &mut self.tags {
187 tags.centre(top, bottom);
188 }
124 flow189 flow
125 }190 }
126}191}
...@@ -131,19 +196,27 @@ fn verticals<'a>(...@@ -131,19 +196,27 @@ fn verticals<'a>(
131 tables: &'a mut [TableLayout],196 tables: &'a mut [TableLayout],
132 objects: &'a mut [ObjectLayout],197 objects: &'a mut [ObjectLayout],
133) -> impl Iterator<Item = &'a mut f32> {198) -> impl Iterator<Item = &'a mut f32> {
134 let cells = tables.iter_mut().flat_map(|table| &mut table.cells);199 let tags = |tags: &'a mut Option<BlockTags>| {
200 tags.iter_mut()
201 .flat_map(|tags| &mut tags.tags)
202 .map(|tag| &mut tag.origin[1])
203 };
135 paragraphs204 paragraphs
136 .iter_mut()205 .iter_mut()
137 .map(|paragraph| &mut paragraph.origin[1])206 .map(|paragraph| &mut paragraph.origin[1])
138 .chain(cells.flat_map(|cell| {207 .chain(tables.iter_mut().flat_map(move |table| {
139 let [_, top, _, bottom] = &mut cell.rect;208 let cells = table.cells.iter_mut().flat_map(|cell| {
140 [top, bottom]209 let [_, top, _, bottom] = &mut cell.rect;
210 [top, bottom]
211 });
212 cells.chain(tags(&mut table.tags))
141 }))213 }))
142 .chain(objects.iter_mut().flat_map(|object| {214 .chain(objects.iter_mut().flat_map(move |object| {
143 let ObjectLayout {215 let ObjectLayout {
144 rect: [_, top, _, end],216 rect: [_, top, _, end],
145 bottom,217 bottom,
146 kind,218 kind,
219 tags: block,
147 ..220 ..
148 } = object;221 } = object;
149 let label = match kind {222 let label = match kind {
...@@ -152,7 +225,10 @@ fn verticals<'a>(...@@ -152,7 +225,10 @@ fn verticals<'a>(
152 }225 }
153 ObjectKind::Picture | ObjectKind::Ink(_) => None,226 ObjectKind::Picture | ObjectKind::Ink(_) => None,
154 };227 };
155 [top, end, bottom].into_iter().chain(label)228 [top, end, bottom]
229 .into_iter()
230 .chain(label)
231 .chain(tags(block))
156 }))232 }))
157}233}
158234
...@@ -167,6 +243,7 @@ pub struct TableLayout {...@@ -167,6 +243,7 @@ pub struct TableLayout {
167 /// Cells are in paragraph order with disjoint visible ranges.243 /// Cells are in paragraph order with disjoint visible ranges.
168 pub cells: Vec<CellLayout>,244 pub cells: Vec<CellLayout>,
169 pub borders: bool,245 pub borders: bool,
246 pub tags: Option<BlockTags>,
170}247}
171248
172#[derive(Clone)]249#[derive(Clone)]
...@@ -515,6 +592,7 @@ fn file_column(...@@ -515,6 +592,7 @@ fn file_column(
515 rect: [left, 0.0, left + w, 0.0],592 rect: [left, 0.0, left + w, 0.0],
516 kind: ObjectKind::File(label),593 kind: ObjectKind::File(label),
517 bottom: 0.0,594 bottom: 0.0,
595 tags: None,
518 };596 };
519 Ok((object, h))597 Ok((object, h))
520}598}
...@@ -1104,13 +1182,16 @@ impl ParagraphLayout {...@@ -1104,13 +1182,16 @@ impl ParagraphLayout {
1104 self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min)1182 self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min)
1105 }1183 }
11061184
1107 /// Places the tags for siblings whose leftmost marker starts at `marker`; later tags
1108 /// follow the first to its right, toward the text.
1109 fn place_tags(&mut self, marker: Option<f32>) {1185 fn place_tags(&mut self, marker: Option<f32>) {
1110 for (index, tag) in self.tags.iter_mut().enumerate() {1186 place_tags(&mut self.tags, self.origin[0], marker);
1111 tag.origin[0] =1187 }
1112 ParagraphTag::column(self.origin[0], marker, tag.size) + tag.size * index as f32;1188}
1113 }1189
1190/// Places the tags of content at `x` among siblings whose leftmost marker starts at
1191/// `marker`; later tags follow the first to its right, toward the content.
1192fn place_tags(tags: &mut [ParagraphTag], x: f32, marker: Option<f32>) {
1193 for (index, tag) in tags.iter_mut().enumerate() {
1194 tag.origin[0] = ParagraphTag::column(x, marker, tag.size) + tag.size * index as f32;
1114 }1195 }
1115}1196}
11161197
...@@ -1136,10 +1217,39 @@ impl OutlineLayout {...@@ -1136,10 +1217,39 @@ impl OutlineLayout {
1136 -self1217 -self
1137 .paragraphs1218 .paragraphs
1138 .iter()1219 .iter()
1139 .map(|p| p.tags.iter().skip(1).map(|tag| tag.size).sum::<f32>())1220 .map(|p| p.tags.as_slice())
1221 .chain(self.block_tags().map(|block| block.tags.as_slice()))
1222 .map(|tags| tags.iter().skip(1).map(|tag| tag.size).sum::<f32>())
1140 .fold(0.0, f32::max)1223 .fold(0.0, f32::max)
1141 }1224 }
11421225
1226 fn block_tags(&self) -> impl Iterator<Item = &BlockTags> {
1227 let tables = self.tables.iter().filter_map(|table| table.tags.as_ref());
1228 tables.chain(
1229 self.objects
1230 .iter()
1231 .filter_map(|object| object.tags.as_ref()),
1232 )
1233 }
1234
1235 /// Every note tag the outline draws, with the paragraph it marks and its outline-local
1236 /// origin before `tag_column_offset`.
1237 pub fn tags(&self) -> impl Iterator<Item = (ExGuid, [f32; 2], &ParagraphTag)> {
1238 let text = self.paragraphs.iter().flat_map(|paragraph| {
1239 paragraph.tags.iter().map(move |tag| {
1240 let y = paragraph.origin[1] + tag.origin[1];
1241 (paragraph.id, [tag.origin[0], y], tag)
1242 })
1243 });
1244 let blocks = self.block_tags().flat_map(|block| {
1245 block
1246 .tags
1247 .iter()
1248 .map(|tag| (block.paragraph, tag.origin, tag))
1249 });
1250 text.chain(blocks)
1251 }
1252
1143 /// Innermost table cell containing a visible paragraph index.1253 /// Innermost table cell containing a visible paragraph index.
1144 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {1254 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {
1145 self.tables.iter().rev().find_map(|table| {1255 self.tables.iter().rev().find_map(|table| {
...@@ -1172,15 +1282,22 @@ impl OutlineLayout {...@@ -1172,15 +1282,22 @@ impl OutlineLayout {
1172 *value += offset;1282 *value += offset;
1173 }1283 }
1174 }1284 }
1285 if let Some(tags) = &mut table.tags {
1286 tags.offset(origin);
1287 }
1175 }1288 }
1176 for object in &mut child.objects {1289 for object in &mut child.objects {
1177 for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) {1290 for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) {
1178 *value += offset;1291 *value += offset;
1179 }1292 }
1180 object.bottom += origin[1];1293 object.bottom += origin[1];
1294 if let Some(tags) = &mut object.tags {
1295 tags.offset(origin);
1296 }
1181 if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind {1297 if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind {
1298 let y = label.origin[1];
1182 label.reset_origin(label.origin[0] + origin[0]);1299 label.reset_origin(label.origin[0] + origin[0]);
1183 label.origin[1] += origin[1];1300 label.origin[1] = y + origin[1];
1184 }1301 }
1185 }1302 }
1186 self.paragraphs.extend(child.paragraphs);1303 self.paragraphs.extend(child.paragraphs);
...@@ -1231,6 +1348,21 @@ impl OutlineLayout {...@@ -1231,6 +1348,21 @@ impl OutlineLayout {
1231 let marker = markers.get(&paragraph.parent).copied();1348 let marker = markers.get(&paragraph.parent).copied();
1232 paragraph.place_tags(marker);1349 paragraph.place_tags(marker);
1233 }1350 }
1351 let tables = self
1352 .tables
1353 .iter_mut()
1354 .filter_map(|table| table.tags.as_mut());
1355 let objects = self
1356 .objects
1357 .iter_mut()
1358 .filter_map(|object| object.tags.as_mut());
1359 for block in tables.chain(objects) {
1360 place_tags(
1361 &mut block.tags,
1362 block.x,
1363 markers.get(&block.parent).copied(),
1364 );
1365 }
1234 }1366 }
12351367
1236 /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of1368 /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of
...@@ -1299,6 +1431,11 @@ impl OutlineLayout {...@@ -1299,6 +1431,11 @@ impl OutlineLayout {
1299 let space = spacing(&node.format)?;1431 let space = spacing(&node.format)?;
1300 let y = top(&mut state, space);1432 let y = top(&mut state, space);
1301 let mut child = Self::table(table, depth + 1, edit, shape)?;1433 let mut child = Self::table(table, depth + 1, edit, shape)?;
1434 child.tables[0].tags =
1435 BlockTags::new(node, &table.tags, 0.0, shape)?.map(|mut tags| {
1436 tags.centre(0.0, child.size[1]);
1437 tags
1438 });
1302 if depth == 0 {1439 if depth == 0 {
1303 block.rel = verticals(1440 block.rel = verticals(
1304 &mut child.paragraphs,1441 &mut child.paragraphs,
...@@ -1325,6 +1462,7 @@ impl OutlineLayout {...@@ -1325,6 +1462,7 @@ impl OutlineLayout {
1325 rect: [x, 0.0, x + w, 0.0],1462 rect: [x, 0.0, x + w, 0.0],
1326 kind: ObjectKind::Picture,1463 kind: ObjectKind::Picture,
1327 bottom: 0.0,1464 bottom: 0.0,
1465 tags: None,
1328 };1466 };
1329 (object, h, x + w)1467 (object, h, x + w)
1330 }1468 }
...@@ -1345,6 +1483,7 @@ impl OutlineLayout {...@@ -1345,6 +1483,7 @@ impl OutlineLayout {
1345 rect: [x, 0.0, x + w, 0.0],1483 rect: [x, 0.0, x + w, 0.0],
1346 kind: ObjectKind::Ink(ink.clone()),1484 kind: ObjectKind::Ink(ink.clone()),
1347 bottom: 0.0,1485 bottom: 0.0,
1486 tags: None,
1348 };1487 };
1349 (object, h, x + w)1488 (object, h, x + w)
1350 }1489 }
...@@ -1372,6 +1511,7 @@ impl OutlineLayout {...@@ -1372,6 +1511,7 @@ impl OutlineLayout {
1372 rect: [x, 0.0, x + w, 0.0],1511 rect: [x, 0.0, x + w, 0.0],
1373 kind: ObjectKind::Unsupported(label),1512 kind: ObjectKind::Unsupported(label),
1374 bottom: 0.0,1513 bottom: 0.0,
1514 tags: None,
1375 };1515 };
1376 (object, h, x + w)1516 (object, h, x + w)
1377 }1517 }
...@@ -1379,6 +1519,7 @@ impl OutlineLayout {...@@ -1379,6 +1519,7 @@ impl OutlineLayout {
1379 unreachable!("text and tables are not objects")1519 unreachable!("text and tables are not objects")
1380 }1520 }
1381 };1521 };
1522 object.tags = BlockTags::new(node, &[], x, shape)?;
1382 let flow = object.place(y, height);1523 let flow = object.place(y, height);
1383 result.objects.push(object);1524 result.objects.push(object);
1384 (space, height, flow, extent)1525 (space, height, flow, extent)
...@@ -1755,14 +1896,12 @@ impl OutlineLayout {...@@ -1755,14 +1896,12 @@ impl OutlineLayout {
1755 {1896 {
1756 return Err(LayoutError::InvalidWidth);1897 return Err(LayoutError::InvalidWidth);
1757 }1898 }
1758 if !table.tags.is_empty() {
1759 return Err(LayoutError::UnsupportedContent);
1760 }
1761 let mut result = Self {1899 let mut result = Self {
1762 tables: vec![TableLayout {1900 tables: vec![TableLayout {
1763 id: table.id,1901 id: table.id,
1764 cells: Vec::new(),1902 cells: Vec::new(),
1765 borders: table.borders.unwrap_or(true),1903 borders: table.borders.unwrap_or(true),
1904 tags: None,
1766 }],1905 }],
1767 size: [1906 size: [
1768 (0..table.columns.len())1907 (0..table.columns.len())
...@@ -1799,6 +1938,17 @@ impl OutlineLayout {...@@ -1799,6 +1938,17 @@ impl OutlineLayout {
1799 for paragraph in &mut child.paragraphs {1938 for paragraph in &mut child.paragraphs {
1800 paragraph.parent.get_or_insert(cell.id);1939 paragraph.parent.get_or_insert(cell.id);
1801 }1940 }
1941 let tables = child
1942 .tables
1943 .iter_mut()
1944 .filter_map(|table| table.tags.as_mut());
1945 let objects = child
1946 .objects
1947 .iter_mut()
1948 .filter_map(|object| object.tags.as_mut());
1949 for block in tables.chain(objects) {
1950 block.parent.get_or_insert(cell.id);
1951 }
1802 let paragraph_start = result.paragraphs.len();1952 let paragraph_start = result.paragraphs.len();
1803 result.append(child, [x, y + 3.54]);1953 result.append(child, [x, y + 3.54]);
1804 result.tables[0].cells.push(CellLayout {1954 result.tables[0].cells.push(CellLayout {
...@@ -2614,6 +2764,7 @@ mod tests {...@@ -2614,6 +2764,7 @@ mod tests {
2614 transparency: None,2764 transparency: None,
2615 pen_tip: None,2765 pen_tip: None,
2616 raster_operation: None,2766 raster_operation: None,
2767 pressure: Vec::new(),
2617 }],2768 }],
2618 groups: Vec::new(),2769 groups: Vec::new(),
2619 shape: None,2770 shape: None,
crates/canvas/src/search.rs+1-1
...@@ -77,7 +77,7 @@ impl Query {...@@ -77,7 +77,7 @@ impl Query {
77}77}
7878
79/// Ideographs and kana are words of their own, as their text has no spaces between words.79/// Ideographs and kana are words of their own, as their text has no spaces between words.
80fn ideographic(character: char) -> bool {80pub(crate) fn ideographic(character: char) -> bool {
81 matches!(u32::from(character),81 matches!(u32::from(character),
82 0x3040..=0x30ff | 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x3ffff)82 0x3040..=0x30ff | 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x3ffff)
83}83}
crates/canvas/src/spelling.rs created+578
...@@ -0,0 +1,578 @@
1//! Spelling as OneNote 2010 checks it as you type. Its Proofing defaults decide what is a
2//! word: words in UPPERCASE, words holding numbers, and Internet and file addresses are
3//! left alone, and a word repeating the one before it is marked as repeated. A platform
4//! dictionary checks each paragraph's words on a thread of its own, and results are kept
5//! per paragraph text. Nothing about spelling is stored in the page.
6
7use onestore::page::text::Paragraph;
8use std::collections::{HashMap, HashSet};
9use std::hash::{DefaultHasher, Hash, Hasher};
10use std::ops::Range;
11use std::sync::{Arc, Mutex, mpsc};
12
13/// Checked paragraphs kept before the cache starts over.
14const KEPT: usize = 8192;
15/// Paragraphs waiting to be checked that one call to the dictionary takes.
16const BATCH: usize = 64;
17
18/// A platform spell checker, called from the spelling thread and the host's. Languages are
19/// Windows LCIDs, as runs store them.
20pub trait Dictionary: Send + Sync {
21 /// Whether each word is misspelled in its language; a word in a language no dictionary
22 /// serves is not.
23 fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool>;
24 /// Corrections for `word`, best first.
25 fn suggest(&self, word: &str, language: u32) -> Vec<String>;
26 /// Add to Dictionary: accepts `word` from now on.
27 fn learn(&self, word: &str);
28}
29
30/// Which of a platform's dictionaries, named `en_GB`, `en-GB` or `en`, serves `language`, an
31/// LCID: its locale's, else its language's.
32pub fn pick(language: u32, available: &[String]) -> Option<&str> {
33 let tag = crate::language::tag(language)?.replace('-', "_");
34 let (bare, region) = tag.split_once('_').unwrap_or((&tag, ""));
35 // A bare tag is the locale Windows picks, most often the language's own country.
36 let home = match bare {
37 "en" => "US".to_owned(),
38 _ => bare.to_uppercase(),
39 };
40 let wanted = if region.is_empty() {
41 vec![format!("{bare}_{home}"), bare.to_owned()]
42 } else {
43 vec![tag.clone(), bare.to_owned()]
44 };
45 let name = |dictionary: &String| dictionary.replace('-', "_");
46 wanted
47 .iter()
48 .find_map(|wanted| {
49 available
50 .iter()
51 .find(|dictionary| name(dictionary) == *wanted)
52 })
53 .or_else(|| {
54 available
55 .iter()
56 .find(|dictionary| name(dictionary).starts_with(&format!("{bare}_")))
57 })
58 .map(String::as_str)
59}
60
61/// A marked word: misspelled, or repeating the word before it.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Mark {
64 /// Bytes of the paragraph's text.
65 pub range: Range<usize>,
66 pub repeated: bool,
67}
68
69/// A word of a paragraph: its bytes, its language, and whether its spelling is checked.
70struct Word {
71 range: Range<usize>,
72 language: u32,
73 checked: bool,
74}
75
76/// Letters, digits, underscores and combining marks, as against spaces, punctuation and
77/// symbols.
78fn word_character(character: char) -> bool {
79 !character.is_whitespace()
80 && !character.is_ascii_punctuation()
81 && !matches!(u32::from(character),
82 0x80..=0xbf | 0xd7 | 0xf7 | 0x2000..=0x2bff | 0x3000..=0x303f | 0xfe30..=0xfe4f
83 | 0xff00..=0xff0f | 0x1f000..=0x1faff)
84 || character == '_'
85}
86
87fn apostrophe(character: char) -> bool {
88 matches!(character, '\'' | '\u{2019}' | '\u{02bc}')
89}
90
91/// An Internet or file address, which OneNote leaves unchecked whole.
92fn address(chunk: &str) -> bool {
93 let lower = chunk.to_lowercase();
94 lower.contains("://")
95 || lower.starts_with("www.")
96 || chunk.contains('\\')
97 || chunk
98 .split_once('@')
99 .is_some_and(|(user, host)| !user.is_empty() && host.contains('.'))
100}
101
102/// The words of `paragraph`'s shown text, in order. Hidden field codes and equations are
103/// not text to check, and a word split by one is two.
104fn words(paragraph: &Paragraph) -> Vec<Word> {
105 let text = paragraph.text();
106 let spans = paragraph.spans();
107 let format = |byte: usize| &spans[spans.partition_point(|span| span.end <= byte)].format;
108 // Runs of shown text between spaces, which formatting changes don't split.
109 let mut chunks = Vec::new();
110 let mut start = None;
111 for (byte, character) in text.char_indices() {
112 let format = format(byte);
113 let apart =
114 character.is_whitespace() || format.hidden == Some(true) || format.math == Some(true);
115 match (apart, start) {
116 (true, Some(from)) => {
117 chunks.push(from..byte);
118 start = None;
119 }
120 (false, None) => start = Some(byte),
121 _ => {}
122 }
123 }
124 chunks.extend(start.map(|from| from..text.len()));
125 let mut words = Vec::new();
126 for chunk in chunks {
127 let at = chunk.start;
128 let chunk = &text[chunk];
129 if address(chunk) {
130 continue;
131 }
132 let mut characters = chunk.char_indices().peekable();
133 while let Some((offset, character)) = characters.next() {
134 if !word_character(character) {
135 continue;
136 }
137 let mut end = offset + character.len_utf8();
138 while let Some(&(next, character)) = characters.peek() {
139 let joined = apostrophe(character)
140 && chunk[next + character.len_utf8()..]
141 .chars()
142 .next()
143 .is_some_and(char::is_alphabetic);
144 if !word_character(character) && !joined {
145 break;
146 }
147 end = next + character.len_utf8();
148 characters.next();
149 }
150 let word = &chunk[offset..end];
151 if word
152 .chars()
153 .any(|character| character.is_numeric() || crate::search::ideographic(character))
154 {
155 continue;
156 }
157 words.push(Word {
158 range: at + offset..at + end,
159 language: format(at + offset)
160 .language
161 .unwrap_or(crate::language::EN_US),
162 checked: word.chars().any(char::is_lowercase),
163 });
164 }
165 }
166 words
167}
168
169/// Whether two words are the same word, as a repeated one is.
170fn same(a: &str, b: &str) -> bool {
171 let fold = |word: &str| {
172 word.chars()
173 .flat_map(char::to_lowercase)
174 .map(|character| {
175 if apostrophe(character) {
176 '\''
177 } else {
178 character
179 }
180 })
181 .collect::<String>()
182 };
183 fold(a) == fold(b)
184}
185
186/// Which of `words` repeat the word before them, with only spaces between.
187fn repeated(text: &str, words: &[Word]) -> Vec<bool> {
188 words
189 .iter()
190 .enumerate()
191 .map(|(index, word)| {
192 index.checked_sub(1).is_some_and(|previous| {
193 let previous = &words[previous].range;
194 text[previous.end..word.range.start]
195 .chars()
196 .all(char::is_whitespace)
197 && same(&text[previous.clone()], &text[word.range.clone()])
198 })
199 })
200 .collect()
201}
202
203/// The marks `dictionary` gives each of `paragraphs`, asking it once: each word repeating
204/// the one before it, and each other checked word it finds misspelled.
205fn check(paragraphs: &[&Paragraph], dictionary: &dyn Dictionary) -> Vec<Vec<Mark>> {
206 let words: Vec<(Vec<Word>, Vec<bool>)> = paragraphs
207 .iter()
208 .map(|paragraph| {
209 let words = words(paragraph);
210 let repeated = repeated(paragraph.text(), &words);
211 (words, repeated)
212 })
213 .collect();
214 let asked: Vec<(&str, u32)> = paragraphs
215 .iter()
216 .zip(&words)
217 .flat_map(|(paragraph, (words, repeated))| {
218 words
219 .iter()
220 .zip(repeated)
221 .filter(|(word, repeated)| word.checked && !**repeated)
222 .map(|(word, _)| (&paragraph.text()[word.range.clone()], word.language))
223 })
224 .collect();
225 let mut misspelled = dictionary.misspelled(&asked).into_iter();
226 words
227 .into_iter()
228 .map(|(words, repeated)| {
229 words
230 .into_iter()
231 .zip(repeated)
232 .filter_map(|(word, repeated)| {
233 let marked = repeated || word.checked && misspelled.next().unwrap_or(false);
234 marked.then_some(Mark {
235 range: word.range,
236 repeated,
237 })
238 })
239 .collect()
240 })
241 .collect()
242}
243
244/// What a paragraph's marks are kept under: its text and the formatting that decides them.
245fn key(paragraph: &Paragraph) -> u64 {
246 let mut hasher = DefaultHasher::new();
247 paragraph.text().hash(&mut hasher);
248 for span in paragraph.spans() {
249 (
250 span.end,
251 span.format.language,
252 span.format.hidden,
253 span.format.math,
254 )
255 .hash(&mut hasher);
256 }
257 hasher.finish()
258}
259
260struct Checked {
261 text: String,
262 marks: Vec<Mark>,
263}
264
265#[derive(Default)]
266struct State {
267 checked: HashMap<u64, Checked>,
268 pending: HashSet<u64>,
269 /// Words Ignore or Add to Dictionary accepted, misspelled or repeated, left unmarked
270 /// everywhere.
271 accepted: HashSet<String>,
272}
273
274impl State {
275 /// The marks kept for `paragraph` under `key`, less accepted words.
276 fn marks(&self, key: u64, paragraph: &Paragraph) -> Option<Vec<Mark>> {
277 let checked = self
278 .checked
279 .get(&key)
280 .filter(|checked| checked.text == paragraph.text())?;
281 Some(
282 checked
283 .marks
284 .iter()
285 .filter(|mark| {
286 !self
287 .accepted
288 .contains(&paragraph.text()[mark.range.clone()])
289 })
290 .cloned()
291 .collect(),
292 )
293 }
294
295 fn keep(&mut self, key: u64, paragraph: &Paragraph, marks: Vec<Mark>) {
296 if self.checked.len() >= KEPT {
297 self.checked.clear();
298 }
299 self.checked.insert(
300 key,
301 Checked {
302 text: paragraph.text().to_owned(),
303 marks,
304 },
305 );
306 }
307}
308
309struct Shared {
310 dictionary: Box<dyn Dictionary>,
311 state: Mutex<State>,
312}
313
314/// Spell checking shared by the pages of one app. Paragraphs are checked when first asked
315/// for, on a thread of its own, which wakes `redraw` when marks are ready.
316#[derive(Clone)]
317pub struct Spelling {
318 shared: Arc<Shared>,
319 jobs: mpsc::Sender<(u64, Paragraph)>,
320}
321
322impl Spelling {
323 pub fn new(dictionary: Box<dyn Dictionary>, redraw: std::task::Waker) -> Self {
324 let shared = Arc::new(Shared {
325 dictionary,
326 state: Mutex::default(),
327 });
328 let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>();
329 let worker = Arc::clone(&shared);
330 std::thread::Builder::new()
331 .name("spelling".into())
332 .spawn(move || {
333 while let Ok(first) = receiver.recv() {
334 let jobs: Vec<(u64, Paragraph)> = std::iter::once(first)
335 .chain(receiver.try_iter().take(BATCH - 1))
336 .collect();
337 let paragraphs: Vec<&Paragraph> =
338 jobs.iter().map(|(_, paragraph)| paragraph).collect();
339 let marks = check(&paragraphs, &*worker.dictionary);
340 let mut state = worker.state.lock().unwrap();
341 for ((key, paragraph), marks) in jobs.iter().zip(marks) {
342 state.pending.remove(key);
343 state.keep(*key, paragraph, marks);
344 }
345 drop(state);
346 redraw.wake_by_ref();
347 }
348 })
349 .expect("the spelling thread starts");
350 Self { shared, jobs }
351 }
352
353 /// The marks on `paragraph` once it is checked, less accepted words; until then none,
354 /// and it is checked.
355 pub fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> {
356 let key = key(paragraph);
357 let mut state = self.shared.state.lock().unwrap();
358 if let Some(marks) = state.marks(key, paragraph) {
359 return marks;
360 }
361 if state.pending.insert(key) {
362 let _ = self.jobs.send((key, paragraph.clone()));
363 }
364 Vec::new()
365 }
366
367 /// The marks on `paragraph`, checked on this thread where not yet checked, as the
368 /// Spelling pane walks the page.
369 pub fn marks_now(&self, paragraph: &Paragraph) -> Vec<Mark> {
370 let key = key(paragraph);
371 if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) {
372 return marks;
373 }
374 let marks = check(&[paragraph], &*self.shared.dictionary).remove(0);
375 let mut state = self.shared.state.lock().unwrap();
376 state.keep(key, paragraph, marks);
377 state.marks(key, paragraph).unwrap_or_default()
378 }
379
380 /// Corrections for `word` in `language`, best first.
381 pub fn suggest(&self, word: &str, language: Option<u32>) -> Vec<String> {
382 self.shared
383 .dictionary
384 .suggest(word, language.unwrap_or(crate::language::EN_US))
385 }
386
387 /// Ignore: leaves `word` unmarked everywhere until the app quits.
388 pub fn ignore(&self, word: &str) {
389 self.shared
390 .state
391 .lock()
392 .unwrap()
393 .accepted
394 .insert(word.to_owned());
395 }
396
397 /// Add to Dictionary: the platform's dictionary accepts `word` from now on.
398 pub fn learn(&self, word: &str) {
399 self.shared.dictionary.learn(word);
400 self.ignore(word);
401 }
402}
403
404#[cfg(test)]
405pub(crate) mod tests {
406 use super::*;
407 use onestore::document::Format;
408
409 /// Knows a few English and French words; English only by default.
410 pub(crate) struct Fake;
411
412 const ENGLISH: &[&str] = &[
413 "this", "sentence", "has", "the", "typos", "word", "don't", "i", "a", "bar", "is", "here",
414 "well", "known", "hello",
415 ];
416 const FRENCH: &[&str] = &["bonjour", "le", "monde", "une", "phrase"];
417
418 impl Dictionary for Fake {
419 fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> {
420 words
421 .iter()
422 .map(|(word, language)| {
423 let known = match language {
424 1033 => ENGLISH,
425 1036 => FRENCH,
426 _ => return false,
427 };
428 !known.contains(&word.to_lowercase().replace('\u{2019}', "'").as_str())
429 })
430 .collect()
431 }
432
433 fn suggest(&self, word: &str, _: u32) -> Vec<String> {
434 match word {
435 "Ths" => vec!["This".into(), "Thus".into()],
436 "sentense" => vec!["sentence".into()],
437 _ => Vec::new(),
438 }
439 }
440
441 fn learn(&self, _: &str) {}
442 }
443
444 #[test]
445 fn languages_pick_their_locale_then_their_language() {
446 let available: Vec<String> = ["en", "en_GB", "fr", "de-AT", "de_DE", "pt_BR"]
447 .map(String::from)
448 .into();
449 for (lcid, picked) in [
450 (1033, Some("en")),
451 (2057, Some("en_GB")),
452 (3081, Some("en")),
453 (3084, Some("fr")),
454 (1031, Some("de_DE")),
455 (3079, Some("de-AT")),
456 (2070, Some("pt_BR")),
457 (1049, None),
458 (0x1007f, None),
459 ] {
460 assert_eq!(pick(lcid, &available), picked, "{lcid}");
461 }
462 let hunspell: Vec<String> = ["en_AU", "en_US", "fr_FR"].map(String::from).into();
463 assert_eq!(pick(1033, &hunspell), Some("en_US"));
464 assert_eq!(pick(1036, &hunspell), Some("fr_FR"));
465 }
466
467 fn marked(paragraph: &Paragraph) -> Vec<(&str, bool)> {
468 check(&[paragraph], &Fake)
469 .remove(0)
470 .into_iter()
471 .map(|mark| (&paragraph.text()[mark.range], mark.repeated))
472 .collect()
473 }
474
475 /// OneNote 2010 under its default Proofing options (`/tmp/snowbound-spelling/lab`).
476 #[test]
477 fn words_are_marked_as_onenote_marks_them() {
478 let paragraph = Paragraph::new(
479 "Ths sentense has the typos. HELLO WRLD NASA abc123 x86 3rd foo_bar \
480 www.exmple.com http://exmple.com/qwrt a@exmple.com C:\\Users\\qwrt \
481 don't dont word word the The don't don\u{2019}t well-knwn (sentense) \
482 \u{65e5}\u{672c}\u{8a9e}"
483 .into(),
484 Format::default(),
485 );
486 assert_eq!(
487 marked(&paragraph),
488 [
489 ("Ths", false),
490 ("sentense", false),
491 ("foo_bar", false),
492 ("dont", false),
493 ("word", true),
494 ("The", true),
495 ("don\u{2019}t", true),
496 ("knwn", false),
497 ("sentense", false),
498 ]
499 );
500 }
501
502 #[test]
503 fn each_run_is_checked_in_its_language_and_hidden_codes_are_not_text() {
504 let french = Format {
505 language: Some(1036),
506 ..Format::default()
507 };
508 let paragraph = Paragraph::from_runs([
509 ("bonjour le monde ".to_owned(), french.clone()),
510 ("bonjjour ".to_owned(), french),
511 (
512 "hello ".to_owned(),
513 Format {
514 language: Some(1061),
515 ..Format::default()
516 },
517 ),
518 (
519 "wo".to_owned(),
520 Format {
521 bold: Some(true),
522 ..Format::default()
523 },
524 ),
525 ("rd ".to_owned(), Format::default()),
526 (
527 "HYPERLINK \"qwrt\"".to_owned(),
528 Format {
529 hidden: Some(true),
530 ..Format::default()
531 },
532 ),
533 ("heere".to_owned(), Format::default()),
534 ]);
535 assert_eq!(marked(&paragraph), [("bonjjour", false), ("heere", false)]);
536 }
537
538 #[test]
539 fn marks_arrive_from_the_thread_and_accepted_words_drop_out() {
540 struct Wake(mpsc::Sender<()>);
541 impl std::task::Wake for Wake {
542 fn wake(self: Arc<Self>) {
543 let _ = self.0.send(());
544 }
545 }
546 let (woken, wakes) = mpsc::channel();
547 let spelling = Spelling::new(Box::new(Fake), Arc::new(Wake(woken)).into());
548 let paragraph = Paragraph::new("Ths sentense here".into(), Format::default());
549 assert!(spelling.marks(&paragraph).is_empty());
550 wakes
551 .recv_timeout(std::time::Duration::from_secs(10))
552 .expect("the spelling thread wakes the host");
553 let words = |spelling: &Spelling| {
554 spelling
555 .marks(&paragraph)
556 .into_iter()
557 .map(|mark| paragraph.text()[mark.range].to_owned())
558 .collect::<Vec<_>>()
559 };
560 assert_eq!(words(&spelling), ["Ths", "sentense"]);
561 spelling.ignore("sentense");
562 assert_eq!(words(&spelling), ["Ths"]);
563 assert_eq!(spelling.suggest("Ths", None), ["This", "Thus"]);
564 // The same text in another language is checked again.
565 let french = Paragraph::new(
566 "Ths sentense here".into(),
567 Format {
568 language: Some(1036),
569 ..Format::default()
570 },
571 );
572 assert!(spelling.marks(&french).is_empty());
573 wakes
574 .recv_timeout(std::time::Duration::from_secs(10))
575 .expect("the spelling thread wakes the host");
576 assert_eq!(spelling.marks(&french).len(), 2);
577 }
578}
crates/canvas/src/template.rs+2
...@@ -43,8 +43,10 @@ impl Template {...@@ -43,8 +43,10 @@ impl Template {
43 },43 },
44 size: art.size,44 size: art.size,
45 bytes: Some(bytes.into()),45 bytes: Some(bytes.into()),
46 display: None,
46 alt: None,47 alt: None,
47 background: true,48 background: true,
49 printout: None,
48 })50 })
49 })51 })
50 .collect()52 .collect()
crates/canvas/tests/cross_container.rs created+169
...@@ -0,0 +1,169 @@
1//! Deleting or typing over a selection that crosses a table's edge does what OneNote 2010 did
2//! with the same keys (`tools/native_cross_container.py`, `corpus/cross-container`): nothing
3//! joins across the edge, cells inside are emptied, and where the selection runs on past the
4//! table its rows inside go, the table with them when all do.
5
6use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
7use onestore::{
8 Arena, ExGuid, Section, Store,
9 op::{Edit, Op},
10 page::{Page, PageObject, PageParagraph, ParagraphContent},
11};
12
13const CROSS: &[u8] = include_bytes!("../../../corpus/cross-container/native/notebook/Cross.one");
14
15/// The body's paragraphs in order: a text, or a table's rows of cell texts.
16#[derive(Debug, PartialEq)]
17enum Block {
18 Text(String),
19 Table(Vec<Vec<String>>),
20}
21
22fn blocks(page: &Page) -> Vec<Block> {
23 let text = |node: &PageParagraph| node.text().unwrap().text.text().to_owned();
24 page.objects
25 .iter()
26 .find_map(|object| match object {
27 PageObject::Outline(outline) => Some(&outline.paragraphs),
28 _ => None,
29 })
30 .unwrap()
31 .iter()
32 .map(|node| match &node.content {
33 ParagraphContent::Table(table) => Block::Table(
34 table
35 .rows
36 .iter()
37 .map(|row| {
38 row.cells
39 .iter()
40 .map(|cell| cell.paragraphs.iter().map(text).collect())
41 .collect()
42 })
43 .collect(),
44 ),
45 _ => Block::Text(text(node)),
46 })
47 .collect()
48}
49
50fn at(paragraph: usize, offset: u32) -> TextPosition {
51 TextPosition { paragraph, offset }
52}
53
54#[test]
55fn deleting_across_a_tables_edge_keeps_each_side() {
56 let arena = Arena::default();
57 let mut section = Section::open(&arena, CROSS.to_vec()).unwrap();
58 let pages = section.pages().unwrap();
59 let mut engine = TextEngine::default();
60 let text = |value: &str| Block::Text(value.into());
61 let table = |rows: &[[&str; 2]]| {
62 Block::Table(
63 rows.iter()
64 .map(|row| row.iter().map(|cell| cell.to_string()).collect())
65 .collect(),
66 )
67 };
68 // Each page's selection, whether Delete is pressed, and what is typed then.
69 let cases = [
70 (
71 "Delete into a table",
72 [at(0, 6), at(1, 5)],
73 true,
74 "",
75 vec![
76 text("Before"),
77 table(&[[" one", "Alpha two"], ["Beta one", "Beta two"]]),
78 text("After text"),
79 ],
80 ),
81 (
82 "Delete out of a table",
83 [at(2, 5), at(5, 5)],
84 true,
85 "X",
86 vec![
87 text("Before text"),
88 table(&[["Alpha one", "AlphaX"]]),
89 text(" text"),
90 ],
91 ),
92 (
93 "Type over a row",
94 [at(0, 6), at(3, 4)],
95 false,
96 "Y",
97 vec![
98 text("BeforeY"),
99 table(&[["", ""], [" one", "Beta two"]]),
100 text("After text"),
101 ],
102 ),
103 (
104 "Delete a whole table",
105 [at(0, 6), at(5, 5)],
106 true,
107 "",
108 vec![text("Before"), text(" text")],
109 ),
110 ];
111 let mut expected = Vec::new();
112 for (index, (title, selection, delete, typed, blocks_after)) in cases.into_iter().enumerate() {
113 let space: ExGuid = pages.iter().find(|page| page.1 == title).unwrap().0;
114 let page = section.page(space).unwrap();
115 let body = page
116 .objects
117 .iter()
118 .find_map(|object| match object {
119 PageObject::Outline(outline) => Some(outline.id),
120 _ => None,
121 })
122 .unwrap();
123 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
124 editor.focus_outline(body).unwrap();
125 editor.select(selection.into()).unwrap();
126 if delete {
127 editor.delete(&mut engine, false).unwrap();
128 }
129 if !typed.is_empty() {
130 editor.insert(&mut engine, typed).unwrap();
131 }
132 assert_eq!(
133 editor.selection().positions[1].paragraph,
134 selection[0].paragraph
135 );
136 let ops = editor
137 .take_ops()
138 .unwrap()
139 .into_iter()
140 .map(|op| Op::Page { space, op })
141 .collect();
142 let at = 134_000_000_000_000_000 + index as u64 * 10_000_000;
143 section.apply("Author", &Edit { at, ops }).unwrap();
144 assert_eq!(
145 blocks(&section.page(space).unwrap()),
146 blocks_after,
147 "{title}"
148 );
149 expected.push((space, blocks_after));
150 }
151 section.seal().unwrap();
152 let written = section.image();
153 let reread = Section::open(&arena, written.clone()).unwrap();
154 for (space, blocks_after) in &expected {
155 assert_eq!(blocks(&reread.page(*space).unwrap()), *blocks_after);
156 }
157 if let Some(directory) = std::env::var_os("SNOWBOUND_CROSS_CONTAINER_EXPORT") {
158 let directory = std::path::PathBuf::from(directory);
159 std::fs::create_dir(&directory).unwrap();
160 std::fs::write(directory.join("Cross.one"), &written).unwrap();
161 let file_id = Store::parse(&written).unwrap().header.file_id;
162 std::fs::write(
163 directory.join("Open Notebook.onetoc2"),
164 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Cross.one", file_id)])
165 .unwrap(),
166 )
167 .unwrap();
168 }
169}
crates/canvas/tests/custom_tags.rs+2
...@@ -24,6 +24,7 @@ fn list() -> Vec<NoteTag> {...@@ -24,6 +24,7 @@ fn list() -> Vec<NoteTag> {
24 shape,24 shape,
25 color,25 color,
26 highlight,26 highlight,
27 art: None,
27 };28 };
28 vec![29 vec![
29 tag("Snow check", 61, Some(0x0000_0080), Some(0x00ff_cc00)),30 tag("Snow check", 61, Some(0x0000_0080), Some(0x00ff_cc00)),
...@@ -173,6 +174,7 @@ fn onenotes_custom_tags_read_back() {...@@ -173,6 +174,7 @@ fn onenotes_custom_tags_read_back() {
173 shape: 61,174 shape: 61,
174 color: Some(0x0000_0080),175 color: Some(0x0000_0080),
175 highlight: Some(0x00ff_cc00),176 highlight: Some(0x00ff_cc00),
177 art: None,
176 };178 };
177 let to_do = NoteTag::defaults()[0].clone();179 let to_do = NoteTag::defaults()[0].clone();
178 // Snow check went in at the top of the list, then moved below To Do.180 // Snow check went in at the top of the list, then moved below To Do.
crates/canvas/tests/ink_tools.rs+36
...@@ -285,3 +285,39 @@ fn drawing_tools_store_each_stroke_and_undo_it_in_one_step() {...@@ -285,3 +285,39 @@ fn drawing_tools_store_each_stroke_and_undo_it_in_one_step() {
285 assert!(lab.view.editor.take_ops().unwrap().is_empty());285 assert!(lab.view.editor.take_ops().unwrap().is_empty());
286 assert_eq!(lab.view.ink_selection(), [drawn[5].id]);286 assert_eq!(lab.view.ink_selection(), [drawn[5].id]);
287}287}
288
289/// A pen reporting pressure stores it at each point, following the pen between reports; a
290/// mouse's stroke after it keeps the pen's width, as OneNote stores one (`corpus/ink-pressure`).
291#[test]
292fn a_pressure_pen_stores_its_pressure_and_a_mouse_its_width() {
293 let source = onestore::create_section("ink.one", "Pressure", "Author").unwrap();
294 let arena = Arena::default();
295 let mut lab = open(&arena, source);
296 let _ = lab.view.set_tool(Tool::Pen(FAVORITES[0]));
297 let path = line([60.0, 150.0], [220.0, 150.0], 4);
298 let view = &mut lab.view;
299 view.set_pressure(Some(0.2));
300 let _ = view.pointer_moved(path[0]).unwrap();
301 let _ = view.pointer_pressed(Instant::now()).unwrap();
302 for (point, pressure) in path[1..]
303 .iter()
304 .zip([Some(0.4), None, Some(1.0), Some(0.9)])
305 {
306 if pressure.is_some() {
307 view.set_pressure(pressure);
308 }
309 let _ = view.pointer_moved(*point).unwrap();
310 }
311 let _ = view.pointer_released().unwrap();
312 lab.store();
313 lab.view.set_pressure(None);
314 lab.drag(&line([60.0, 200.0], [220.0, 200.0], 4));
315 lab.store();
316 let drawn = lab.drawings();
317 let level = onestore::page::ink::level;
318 assert_eq!(
319 drawn[0].strokes[0].pressure,
320 [0.2, 0.4, 0.4, 1.0, 0.9].map(level)
321 );
322 assert!(drawn[1].strokes[0].pressure.is_empty());
323}
crates/canvas/tests/links_equations.rs+263-1
...@@ -156,7 +156,10 @@ fn links_and_equations_save_as_the_editor_shows_them() {...@@ -156,7 +156,10 @@ fn links_and_equations_save_as_the_editor_shows_them() {
156 let at = line(&mut editor, engine);156 let at = line(&mut editor, engine);
157 typed(&mut editor, engine, "Read the Rust book today");157 typed(&mut editor, engine, "Read the Rust book today");
158 select(&mut editor, at, 9..18);158 select(&mut editor, at, 9..18);
159 assert_eq!(editor.link_prefill(), ("Rust book".into(), String::new()));159 assert_eq!(
160 editor.link_prefill(),
161 Some(("Rust book".into(), String::new()))
162 );
160 editor163 editor
161 .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/")164 .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/")
162 .unwrap();165 .unwrap();
...@@ -500,6 +503,265 @@ fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() {...@@ -500,6 +503,265 @@ fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() {
500 assert_eq!(written, native);503 assert_eq!(written, native);
501}504}
502505
506/// Enter and Shift+Enter inside a link's label leave it whole, as in OneNote 2010, where
507/// Shift+Enter does nothing and Enter follows the link; at its end Enter splits as anywhere
508/// else.
509#[test]
510fn enter_inside_a_link_is_ignored() {
511 let mut engine = TextEngine::default();
512 let engine = &mut engine;
513 let mut editor = CanvasEditor::new(
514 engine,
515 canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new(
516 String::new(),
517 Default::default(),
518 )])
519 .unwrap(),
520 400.0,
521 )
522 .unwrap();
523 typed(&mut editor, engine, "Visit the example site today");
524 select(&mut editor, 0, 10..22);
525 editor
526 .set_link(engine, "example site", "https://example.com/")
527 .unwrap();
528 editor.take_ops().unwrap();
529 let document = editor.active_outline().document().clone();
530 let text = document.paragraphs().next().unwrap();
531 let end = text
532 .utf16_offset(text.text().find(" today").unwrap())
533 .unwrap();
534 let label = editor
535 .link_at(TextPosition {
536 paragraph: 0,
537 offset: end,
538 })
539 .unwrap()
540 .label;
541 let inside = label.start + 7;
542 for soft in [false, true] {
543 select(&mut editor, 0, inside..inside);
544 editor.enter(engine, soft).unwrap();
545 assert_eq!(*editor.active_outline().document(), document);
546 assert!(editor.take_ops().unwrap().is_empty());
547 }
548 select(&mut editor, 0, label.end..label.end);
549 editor.enter(engine, false).unwrap();
550 assert_eq!(editor.active_outline().document().paragraphs().count(), 2);
551}
552
553/// Link over a selection across paragraphs does nothing, as OneNote 2010's does.
554#[test]
555fn link_over_paragraphs_does_nothing() {
556 let mut engine = TextEngine::default();
557 let engine = &mut engine;
558 let mut editor = CanvasEditor::new(
559 engine,
560 canvas::document::TextDocument::new(vec![
561 onestore::page::Paragraph::new("One two".into(), Default::default()),
562 onestore::page::Paragraph::new("Three four".into(), Default::default()),
563 ])
564 .unwrap(),
565 400.0,
566 )
567 .unwrap();
568 let document = editor.active_outline().document().clone();
569 editor
570 .select(
571 [
572 TextPosition {
573 paragraph: 0,
574 offset: 4,
575 },
576 TextPosition {
577 paragraph: 1,
578 offset: 5,
579 },
580 ]
581 .into(),
582 )
583 .unwrap();
584 assert_eq!(editor.link_prefill(), None);
585 editor.set_link(engine, "", "https://example.com/").unwrap();
586 assert_eq!(*editor.active_outline().document(), document);
587 assert!(editor.take_ops().unwrap().is_empty());
588}
589
590/// Deleting from inside one equation into the next joins them into one, as OneNote 2010
591/// joined "aa+bb" and "cc+dd" to "aa++dd" in the lab; the section it stores reopens in OneNote
592/// (`corpus/equation-join`, exported to `CANVAS_EQUATION_JOIN_EXPORT`).
593#[test]
594fn deleting_between_equations_joins_them() {
595 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
596 let source = std::fs::read(root.join("corpus/link-edit/candidate/links.one")).unwrap();
597 let (space, page) = pages(&source).remove(0);
598 let mut engine = TextEngine::default();
599 let engine = &mut engine;
600 let mut editor = CanvasEditor::from_page(page, engine).unwrap();
601 let body = editor
602 .outlines()
603 .iter()
604 .find(|outline| !outline.title)
605 .unwrap()
606 .id;
607 editor.focus_outline(body).unwrap();
608 let first = line(&mut editor, engine);
609 editor.insert_equation(engine).unwrap();
610 typed(&mut editor, engine, "aa+bb ");
611 let second = line(&mut editor, engine);
612 editor.insert_equation(engine).unwrap();
613 typed(&mut editor, engine, "cc+dd ");
614 let text = |editor: &CanvasEditor, paragraph: usize| {
615 editor
616 .active_outline()
617 .document()
618 .paragraphs()
619 .nth(paragraph)
620 .unwrap()
621 .clone()
622 };
623 let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| {
624 let text = text(editor, paragraph);
625 text.utf16_offset(text.text().find(needle).unwrap())
626 .unwrap()
627 };
628 let (from, to) = (before(&editor, first, "𝑏𝑏"), before(&editor, second, "+𝑑"));
629 editor
630 .select(
631 [
632 TextPosition {
633 paragraph: first,
634 offset: from,
635 },
636 TextPosition {
637 paragraph: second,
638 offset: to,
639 },
640 ]
641 .into(),
642 )
643 .unwrap();
644 editor.delete(engine, false).unwrap();
645 let joined = text(&editor, first);
646 assert_eq!(joined.text().trim_end(), "𝑎𝑎++𝑑𝑑");
647 assert!(
648 joined
649 .spans()
650 .iter()
651 .all(|span| span.format.math == Some(true))
652 );
653 let arena = Arena::default();
654 let mut section = Section::open(&arena, source).unwrap();
655 let ops = editor
656 .take_ops()
657 .unwrap()
658 .into_iter()
659 .map(|op| onestore::op::Op::Page { space, op })
660 .collect();
661 section
662 .apply(
663 "Author",
664 &onestore::op::Edit {
665 at: 134_000_000_000_000_000,
666 ops,
667 },
668 )
669 .unwrap();
670 section.seal().unwrap();
671 let written = section.image();
672 let (_, stored) = pages(&written).remove(0);
673 let equations: Vec<String> = stored
674 .objects
675 .iter()
676 .filter_map(|object| match object {
677 PageObject::Outline(outline) if !outline.title => Some(outline),
678 _ => None,
679 })
680 .flat_map(|outline| &outline.paragraphs)
681 .filter_map(|paragraph| paragraph.text())
682 .filter(|text| {
683 text.text
684 .spans()
685 .iter()
686 .any(|span| span.format.math == Some(true))
687 })
688 .map(|text| text.text.text().trim_end().to_owned())
689 .collect();
690 assert_eq!(equations, ["𝑎𝑎++𝑑𝑑"]);
691 if let Some(directory) = std::env::var_os("CANVAS_EQUATION_JOIN_EXPORT") {
692 let directory = std::path::PathBuf::from(directory);
693 std::fs::create_dir(&directory).unwrap();
694 std::fs::write(directory.join("links.one"), &written).unwrap();
695 let file_id = Store::parse(&written).unwrap().header.file_id;
696 std::fs::write(
697 directory.join("Open Notebook.onetoc2"),
698 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", file_id)])
699 .unwrap(),
700 )
701 .unwrap();
702 }
703}
704
705/// Alt+= over a selection across paragraphs makes each paragraph's part an equation of its
706/// own, as OneNote 2010 does, and one undo takes them back.
707#[test]
708fn equation_over_paragraphs_makes_one_each() {
709 let mut engine = TextEngine::default();
710 let engine = &mut engine;
711 let mut editor = CanvasEditor::new(
712 engine,
713 canvas::document::TextDocument::new(vec![
714 onestore::page::Paragraph::new("One two".into(), Default::default()),
715 onestore::page::Paragraph::new("Three four".into(), Default::default()),
716 ])
717 .unwrap(),
718 400.0,
719 )
720 .unwrap();
721 let document = editor.active_outline().document().clone();
722 editor
723 .select(
724 [
725 TextPosition {
726 paragraph: 0,
727 offset: 4,
728 },
729 TextPosition {
730 paragraph: 1,
731 offset: 5,
732 },
733 ]
734 .into(),
735 )
736 .unwrap();
737 editor.insert_equation(engine).unwrap();
738 let math: Vec<Vec<(String, bool)>> = editor
739 .active_outline()
740 .document()
741 .paragraphs()
742 .map(|text| {
743 let mut start = 0;
744 text.spans()
745 .iter()
746 .map(|span| {
747 let part = text.text()[start..span.end].to_owned();
748 start = span.end;
749 (part, span.format.math == Some(true))
750 })
751 .collect()
752 })
753 .collect();
754 assert_eq!(
755 math,
756 [
757 vec![("One ".to_owned(), false), ("𝑡𝑤𝑜".to_owned(), true)],
758 vec![("𝑇ℎ𝑟𝑒𝑒".to_owned(), true), (" four".to_owned(), false)],
759 ]
760 );
761 assert!(editor.undo(engine).unwrap());
762 assert_eq!(*editor.active_outline().document(), document);
763}
764
503/// An equation among text draws in its line, which grows to hold it, and URL text shows as a765/// An equation among text draws in its line, which grows to hold it, and URL text shows as a
504/// link without being stored as one.766/// link without being stored as one.
505#[test]767#[test]
crates/canvas/tests/object_outlines.rs created+100
...@@ -0,0 +1,100 @@
1//! An outline holding only a picture or a file edits like any other: a click beside the object
2//! puts the caret in a paragraph after it, stored only once typed in, as OneNote 2010 adds one
3//! (`corpus/object-outline`).
4
5use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
6use onestore::{
7 Arena, Section, Store,
8 op::{Edit, Op},
9 page::{Page, PageObject, ParagraphContent},
10};
11
12const ALONE: &[u8] = include_bytes!("../../../corpus/object-outline/native/notebook/Alone.one");
13
14/// The body outline's paragraphs: an object's kind, or text.
15fn body(page: &Page) -> Vec<String> {
16 page.objects
17 .iter()
18 .find_map(|object| match object {
19 PageObject::Outline(outline) => Some(&outline.paragraphs),
20 _ => None,
21 })
22 .unwrap()
23 .iter()
24 .map(|node| match &node.content {
25 ParagraphContent::Text(text) => text.text.text().to_owned(),
26 ParagraphContent::Image(_) => "picture".into(),
27 ParagraphContent::Attachment(_) => "file".into(),
28 _ => "other".into(),
29 })
30 .collect()
31}
32
33/// `SNOWBOUND_OBJECT_OUTLINE_EXPORT` names a new directory receiving the candidate for a cold
34/// reopen.
35#[test]
36fn typing_beside_an_outlines_only_object_adds_a_paragraph() {
37 let arena = Arena::default();
38 let mut section = Section::open(&arena, ALONE.to_vec()).unwrap();
39 let pages = section.pages().unwrap();
40 let mut engine = TextEngine::default();
41 for (index, (title, object)) in [("File alone", "file"), ("Picture alone", "picture")]
42 .into_iter()
43 .enumerate()
44 {
45 let space = pages.iter().find(|page| page.1 == title).unwrap().0;
46 let page = section.page(space).unwrap();
47 assert_eq!(body(&page), [object]);
48 let outline = page
49 .objects
50 .iter()
51 .find_map(|object| match object {
52 PageObject::Outline(outline) => Some(outline.id),
53 _ => None,
54 })
55 .unwrap();
56 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
57 assert!(editor.has_page_outline(outline));
58 // The paragraph the caret stands in is not stored until typed in.
59 assert_eq!(editor.page().unwrap(), page);
60 editor.focus_outline(outline).unwrap();
61 let at = TextPosition {
62 paragraph: 0,
63 offset: 0,
64 };
65 editor.select([at; 2].into()).unwrap();
66 assert!(editor.take_ops().unwrap().is_empty());
67 let mut publish = |editor: &mut CanvasEditor, step: u64| {
68 let ops = editor
69 .take_ops()
70 .unwrap()
71 .into_iter()
72 .map(|op| Op::Page { space, op })
73 .collect();
74 let at = 134_000_000_000_000_000 + (index as u64 * 4 + step) * 10_000_000;
75 section.apply("Author", &Edit { at, ops }).unwrap();
76 body(&section.page(space).unwrap())
77 };
78 editor.insert(&mut engine, "abc").unwrap();
79 assert_eq!(publish(&mut editor, 0), [object, "abc"]);
80 // Undoing the typing removes the paragraph again, as OneNote 2010's undo does.
81 assert!(editor.undo(&mut engine).unwrap());
82 assert_eq!(publish(&mut editor, 1), [object]);
83 assert!(editor.redo(&mut engine).unwrap());
84 assert_eq!(publish(&mut editor, 2), [object, "abc"]);
85 }
86 section.seal().unwrap();
87 let written = section.image();
88 if let Some(directory) = std::env::var_os("SNOWBOUND_OBJECT_OUTLINE_EXPORT") {
89 let directory = std::path::PathBuf::from(directory);
90 std::fs::create_dir(&directory).unwrap();
91 std::fs::write(directory.join("Alone.one"), &written).unwrap();
92 let file_id = Store::parse(&written).unwrap().header.file_id;
93 std::fs::write(
94 directory.join("Open Notebook.onetoc2"),
95 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Alone.one", file_id)])
96 .unwrap(),
97 )
98 .unwrap();
99 }
100}
crates/canvas/tests/ops_differential.rs+10-2
...@@ -598,8 +598,10 @@ fn perform(...@@ -598,8 +598,10 @@ fn perform(
598 },598 },
599 size: Some([174.5, 640.0]),599 size: Some([174.5, 640.0]),
600 bytes: Some(std::sync::Arc::from(ART.as_slice())),600 bytes: Some(std::sync::Arc::from(ART.as_slice())),
601 display: None,
601 alt: None,602 alt: None,
602 background: true,603 background: true,
604 printout: None,
603 };605 };
604 let shown = editor.page().unwrap().objects.iter().any(|object| {606 let shown = editor.page().unwrap().objects.iter().any(|object| {
605 matches!(object, onestore::page::PageObject::Image(image) if image.background)607 matches!(object, onestore::page::PageObject::Image(image) if image.background)
...@@ -710,10 +712,16 @@ fn perform(...@@ -710,10 +712,16 @@ fn perform(
710 let points: Vec<[f32; 2]> = (0..12)712 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()])713 .map(|step| [x + 6.0 * step as f32, y + 9.0 * (step as f32 / 2.0).sin()])
712 .collect();714 .collect();
715 // Every other stroke a pressure pen's.
716 let pressure: Vec<f32> = if random.below(2) == 0 {
717 (0..12).map(|step| step as f32 / 11.0).collect()
718 } else {
719 Vec::new()
720 };
713 let ink = onestore::page::Ink {721 let ink = onestore::page::Ink {
714 id: onestore::page::text::new_id().unwrap(),722 id: onestore::page::text::new_id().unwrap(),
715 layout: Default::default(),723 layout: Default::default(),
716 strokes: vec![pen.stroke(&points).unwrap()],724 strokes: vec![pen.stroke(&points, &pressure).unwrap()],
717 groups: Vec::new(),725 groups: Vec::new(),
718 shape: None,726 shape: None,
719 };727 };
...@@ -735,7 +743,7 @@ fn perform(...@@ -735,7 +743,7 @@ fn perform(
735 from[0] + 18.0 * (1 + random.below(6)) as f32,743 from[0] + 18.0 * (1 + random.below(6)) as f32,
736 from[1] + 18.0 * (1 + random.below(4)) as f32,744 from[1] + 18.0 * (1 + random.below(4)) as f32,
737 ];745 ];
738 let pen = Pen::new(50.0, Some(0x7a9a1f)).stroke(&[]).unwrap();746 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();747 let ink = onestore::page::Ink::drawn(kinds[random.below(4)], from, to, &pen).unwrap();
740 editor.draw(ink).is_ok()748 editor.draw(ink).is_ok()
741 }749 }
crates/canvas/tests/printouts.rs created+121
...@@ -0,0 +1,121 @@
1//! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG
2//! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like
3//! any picture: its pages stay printouts when moved, and a page deleted comes back on undo
4//! as the picture OneNote showed of it (`corpus/printout`).
5
6use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
7use onestore::{
8 Arena, Section, Store,
9 op::{Edit, Op},
10 page::{Image, Page, PageObject},
11};
12
13const PRINTOUTS: &[u8] = include_bytes!("../../../corpus/printout/native/notebook/Printouts.one");
14
15fn pictures(page: &Page) -> Vec<&Image> {
16 page.objects
17 .iter()
18 .filter_map(|object| match object {
19 PageObject::Image(image) => Some(image),
20 _ => None,
21 })
22 .collect()
23}
24
25/// `SNOWBOUND_PRINTOUT_EXPORT` names a new directory receiving the candidate for a cold
26/// reopen.
27#[test]
28fn printouts_show_onenotes_rendering_and_move_as_pictures() {
29 let arena = Arena::default();
30 let mut section = Section::open(&arena, PRINTOUTS.to_vec()).unwrap();
31 let (space, ..) = section
32 .pages()
33 .unwrap()
34 .into_iter()
35 .find(|page| page.1 == "Printout")
36 .unwrap();
37 let page = section.page(space).unwrap();
38 let printed = pictures(&page);
39 assert_eq!(printed.len(), 2);
40 for picture in &printed {
41 let (stored, shown) = (
42 picture.bytes.as_ref().unwrap(),
43 picture.display.as_ref().unwrap(),
44 );
45 // The stored picture is the XPS package; the shown one a PNG.
46 assert!(stored.starts_with(b"PK\x03\x04"));
47 assert!(shown.starts_with(b"\x89PNG"));
48 }
49 assert!(Iterator::eq(
50 printed.iter().map(|picture| picture.bytes.as_ref()),
51 [printed[0].bytes.as_ref(); 2]
52 ));
53 let (moved, layout) = (printed[1].id, printed[1].layout.clone());
54 let origin = [layout.x.unwrap(), layout.y.unwrap() + 36.0];
55 let size = [layout.max_width.unwrap(), layout.max_height.unwrap()];
56
57 let mut engine = TextEngine::default();
58 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
59 #[cfg(feature = "gpu")]
60 {
61 let (scene, editor) =
62 canvas::gpu::page::PageScene::from_page(page.clone(), &mut engine).unwrap();
63 assert_eq!(scene.read_only(Some(&editor)).count(), 0);
64 }
65 editor
66 .place_image(&mut engine, moved, origin, size)
67 .unwrap();
68 let restored = printed[0].id;
69 editor.remove_image(&mut engine, restored).unwrap();
70 assert!(editor.undo(&mut engine).unwrap());
71 let body = editor.outlines().iter().find(|o| !o.title).unwrap().id;
72 editor.focus_outline(body).unwrap();
73 let at = TextPosition {
74 paragraph: 0,
75 offset: 0,
76 };
77 editor.select([at; 2].into()).unwrap();
78 editor.insert(&mut engine, "Printed ").unwrap();
79 let ops = editor
80 .take_ops()
81 .unwrap()
82 .into_iter()
83 .map(|op| Op::Page { space, op })
84 .collect();
85 section
86 .apply(
87 "Author",
88 &Edit {
89 at: 134_000_000_000_000_000,
90 ops,
91 },
92 )
93 .unwrap();
94 section.seal().unwrap();
95 let written = section.image();
96 let reread = Section::open(&arena, written.clone()).unwrap();
97 let page = reread.page(space).unwrap();
98 let after = pictures(&page);
99 assert_eq!(after.len(), 2);
100 let moved = after.iter().find(|picture| picture.id == moved).unwrap();
101 assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some));
102 assert!(moved.printout.is_some() && moved.display.is_some());
103 let restored = after.iter().find(|picture| picture.id == restored).unwrap();
104 assert_eq!(restored.printout, None);
105 assert_eq!(restored.bytes, printed[0].display);
106 if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") {
107 let directory = std::path::PathBuf::from(directory);
108 std::fs::create_dir(&directory).unwrap();
109 std::fs::write(directory.join("Printouts.one"), &written).unwrap();
110 let file_id = Store::parse(&written).unwrap().header.file_id;
111 std::fs::write(
112 directory.join("Open Notebook.onetoc2"),
113 onestore::create_table_of_contents(
114 "Open Notebook.onetoc2",
115 &[("Printouts.one", file_id)],
116 )
117 .unwrap(),
118 )
119 .unwrap();
120 }
121}
crates/canvas/tests/recording.rs+65
...@@ -185,3 +185,68 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() {...@@ -185,3 +185,68 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() {
185 assert_eq!(shown[3].1.time_ms, Some(20131));185 assert_eq!(shown[3].1.time_ms, Some(20131));
186 assert_eq!(shown[1].1, MediaIndex::default());186 assert_eq!(shown[1].1, MediaIndex::default());
187}187}
188
189/// A note written while recording is paused links to nothing, and the pause is left out of
190/// later moments, as OneNote 2010 links them (`corpus/recording/native/read/paused-while-recording.xml`).
191/// See Playback then highlights the note linked last at or before the moment playing.
192#[test]
193fn notes_written_while_paused_stay_unlinked_and_see_playback_follows_the_rest() {
194 let source = onestore::create_section("files.one", "Recorded", "Author").unwrap();
195 let arena = Arena::default();
196 let mut section = Section::open(&arena, source).unwrap();
197 let (space, ..) = section.pages().unwrap()[0].clone();
198 let mut engine = TextEngine::default();
199 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
200 let outline = editor
201 .outlines()
202 .iter()
203 .find(|outline| !outline.title)
204 .unwrap()
205 .id;
206 editor.focus_outline(outline).unwrap();
207 editor
208 .move_selection(&mut engine, Movement::DocumentEnd, false)
209 .unwrap();
210 let id = editor.start_recording(&mut engine, "Started").unwrap();
211 let wait = |ms| std::thread::sleep(std::time::Duration::from_millis(ms));
212 wait(30);
213 editor.insert(&mut engine, "Before the pause").unwrap();
214 editor.pause_recording(true);
215 assert!(editor.recording_paused());
216 let paused_at = editor.recording_ms().unwrap();
217 editor.insert(&mut engine, "\nWhile paused").unwrap();
218 wait(300);
219 assert_eq!(editor.recording_ms(), Some(paused_at));
220 editor.pause_recording(false);
221 wait(30);
222 editor.insert(&mut engine, "\nAfter the pause").unwrap();
223 let page = editor.page().unwrap();
224 let shown = body(&page);
225 let moment = |text: &str| {
226 shown
227 .iter()
228 .find(|(shown, _)| shown == text)
229 .unwrap()
230 .1
231 .time_ms
232 };
233 let before = moment("Before the pause").unwrap();
234 let after = moment("After the pause").unwrap();
235 assert_eq!(moment("While paused"), None);
236 assert!(
237 before >= 30 && after >= before + 30 && after < before + 300,
238 "{before} {after}"
239 );
240
241 let note = |at| {
242 editor
243 .played_note(id, at)
244 .and_then(|note| canvas::search::paragraph_match(&editor, note))
245 .map(|(_, selection)| selection.positions[0].paragraph)
246 };
247 // Before the first note only the line saying when, which is never highlighted.
248 assert_eq!(note(before - 1), None);
249 let first = note(before).unwrap();
250 assert_eq!(note(after - 1), Some(first));
251 assert!(note(after).unwrap() > first);
252}
crates/canvas/tests/table_tags.rs created+170
...@@ -0,0 +1,170 @@
1//! Tables carrying note tags edit like any other: the tag draws in the tag column centred on
2//! the table, a click checks its box, typing in a cell keeps the table's tags, and deleting a
3//! table and undoing brings its tags back (`corpus/table-tags`).
4
5use canvas::{
6 document::TextPosition,
7 editor::{CanvasEditor, Whole},
8 layout::TextEngine,
9};
10use onestore::{
11 Arena, ExGuid, Section, Store,
12 document::Tag,
13 op::{Edit, Op},
14 page::{Page, PageObject, PageParagraph, ParagraphContent},
15};
16
17const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one");
18
19/// Each table's cell texts and tags, their arena positions cleared, in page order.
20fn tables(page: &Page) -> Vec<(Vec<String>, Vec<Tag>)> {
21 fn walk(nodes: &[PageParagraph], out: &mut Vec<(Vec<String>, Vec<Tag>)>) {
22 for node in nodes {
23 if let ParagraphContent::Table(table) = &node.content {
24 let cells = table.rows.iter().flat_map(|row| &row.cells);
25 let texts = cells
26 .clone()
27 .flat_map(|cell| &cell.paragraphs)
28 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
29 .collect();
30 let tags = node
31 .tags
32 .iter()
33 .chain(&table.tags)
34 .map(|tag| Tag {
35 extra_set: 0,
36 ..tag.clone()
37 })
38 .collect();
39 out.push((texts, tags));
40 for cell in cells {
41 walk(&cell.paragraphs, out);
42 }
43 }
44 }
45 }
46 let mut out = Vec::new();
47 for object in &page.objects {
48 if let PageObject::Outline(outline) = object {
49 walk(&outline.paragraphs, &mut out);
50 }
51 }
52 out
53}
54
55fn body(page: &Page) -> ExGuid {
56 page.objects
57 .iter()
58 .find_map(|object| match object {
59 PageObject::Outline(outline) => Some(outline.id),
60 _ => None,
61 })
62 .unwrap()
63}
64
65fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) {
66 let ops = editor
67 .take_ops()
68 .unwrap()
69 .into_iter()
70 .map(|op| Op::Page { space, op })
71 .collect();
72 section.apply("Author", &Edit { at, ops }).unwrap();
73}
74
75/// `SNOWBOUND_TABLE_TAGS_EXPORT` names a new directory receiving the candidate for a cold
76/// reopen.
77#[test]
78fn tagged_tables_draw_check_and_survive_edits() {
79 let arena = Arena::default();
80 let mut section = Section::open(&arena, TABLES.to_vec()).unwrap();
81 let pages = section.pages().unwrap();
82 let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0;
83 let (check, edit, restore) = (
84 space("Check a table"),
85 space("Edit a tagged table"),
86 space("Delete and undo"),
87 );
88 let mut engine = TextEngine::default();
89
90 let page = section.page(check).unwrap();
91 let [(_, tags)] = &tables(&page)[..] else {
92 panic!("one table")
93 };
94 assert_eq!(tags.len(), 1);
95 assert_eq!(tags[0].status & 1, 0);
96 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
97 assert!(editor.has_page_outline(body(&page)));
98 let outline = editor
99 .outlines()
100 .iter()
101 .find(|outline| outline.id == body(&page))
102 .unwrap();
103 let holder = outline.document().nodes()[1].id;
104 let [(tagged, origin, tag)] = &outline.shaped().tags().collect::<Vec<_>>()[..] else {
105 panic!("one tag drawn")
106 };
107 assert_eq!(*tagged, holder);
108 // Centred on the table, in the column left of it.
109 let table = &outline.shaped().tables[0];
110 let [top, bottom] = [table.cells[0].rect[1], table.cells[0].rect[3]];
111 assert!((origin[1] + tag.size / 2.0 - (top + bottom) / 2.0).abs() < 2.0);
112 assert!(origin[0] + tag.size < table.cells[0].rect[0]);
113 editor
114 .click_check(&mut engine, body(&page), holder)
115 .unwrap();
116 publish(&mut section, check, &mut editor, 134_000_000_000_000_000);
117 let checked = tables(&section.page(check).unwrap());
118 assert_eq!(checked[0].1[0].status & 1, 1);
119 assert!(checked[0].1[0].completed.is_some_and(|time| time > 0));
120
121 let page = section.page(edit).unwrap();
122 let before = tables(&page);
123 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
124 editor.focus_outline(body(&page)).unwrap();
125 let at = TextPosition {
126 paragraph: 1,
127 offset: 0,
128 };
129 editor.select([at; 2].into()).unwrap();
130 editor.insert(&mut engine, "Now ").unwrap();
131 editor.enter(&mut engine, false).unwrap();
132 publish(&mut section, edit, &mut editor, 134_000_000_010_000_000);
133 let after = tables(&section.page(edit).unwrap());
134 assert_eq!(after[0].0[..2], ["Now ", "Typed into"]);
135 assert_eq!(after[0].1, before[0].1);
136
137 let original = section.page(restore).unwrap();
138 let kept = tables(&original);
139 assert_eq!(kept.len(), 2);
140 let mut editor = CanvasEditor::from_page(original.clone(), &mut engine).unwrap();
141 editor.focus_outline(body(&original)).unwrap();
142 while editor.whole() != Some(Whole::Page) {
143 editor.widen_selection().unwrap();
144 }
145 editor.delete(&mut engine, false).unwrap();
146 publish(&mut section, restore, &mut editor, 134_000_000_020_000_000);
147 assert!(tables(&section.page(restore).unwrap()).is_empty());
148 assert!(editor.undo(&mut engine).unwrap());
149 publish(&mut section, restore, &mut editor, 134_000_000_030_000_000);
150 assert_eq!(tables(&section.page(restore).unwrap()), kept);
151
152 section.seal().unwrap();
153 let written = section.image();
154 let reread = Section::open(&arena, written.clone()).unwrap();
155 assert_eq!(tables(&reread.page(check).unwrap()), checked);
156 assert_eq!(tables(&reread.page(edit).unwrap()), after);
157 assert_eq!(tables(&reread.page(restore).unwrap()), kept);
158 if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_TAGS_EXPORT") {
159 let directory = std::path::PathBuf::from(directory);
160 std::fs::create_dir(&directory).unwrap();
161 std::fs::write(directory.join("Tables.one"), &written).unwrap();
162 let file_id = Store::parse(&written).unwrap().header.file_id;
163 std::fs::write(
164 directory.join("Open Notebook.onetoc2"),
165 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)])
166 .unwrap(),
167 )
168 .unwrap();
169 }
170}
crates/draw/Cargo.toml+2-1
...@@ -8,12 +8,13 @@ publish = false...@@ -8,12 +8,13 @@ publish = false
8default = ["wgpu"]8default = ["wgpu"]
9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10# OpenGL 2.1, for Mac OS X 10.6.10# OpenGL 2.1, for Mac OS X 10.6.
11render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]11render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
12wgpu = ["render", "dep:wgpu"]12wgpu = ["render", "dep:wgpu"]
1313
14[dependencies]14[dependencies]
15parley.workspace = true15parley.workspace = true
1616
17base64 = { version = "0.22.1", optional = true }
17bytemuck = { version = "1.25", features = ["derive"], optional = true }18bytemuck = { version = "1.25", features = ["derive"], optional = true }
18image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }19image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
19linebender_resource_handle = { version = "0.1.1", optional = true }20linebender_resource_handle = { version = "0.1.1", optional = true }
crates/draw/src/draw.glsl+23
...@@ -82,11 +82,34 @@ float shadow(vec2 p, vec2 half_size, float corner, float sigma) {...@@ -82,11 +82,34 @@ float shadow(vec2 p, vec2 half_size, float corner, float sigma) {
82 return value;82 return value;
83}83}
8484
85// Distance from `p` to the tapered capsule from (-h, 0) to (h, 0) with round ends of radii
86// `r0` and `r1` (Inigo Quilez's uneven capsule).
87float taper(vec2 p, float h, float r0, float r1) {
88 vec2 q = vec2(abs(p.y), p.x + h);
89 float span = 2.0 * h;
90 float b = (r0 - r1) / max(span, 1e-6);
91 float far = length(q - vec2(0.0, span)) - r1;
92 if (abs(b) >= 1.0) {
93 return min(length(q) - r0, far);
94 }
95 float a = sqrt(1.0 - b * b);
96 float k = dot(q, vec2(-b, a));
97 if (k < 0.0) {
98 return length(q) - r0;
99 }
100 if (k > a * span) {
101 return far;
102 }
103 return dot(q, vec2(a, b)) - r0;
104}
105
85void main() {106void main() {
86 vec4 color = texture2D(atlas, v_uv) * v_color;107 vec4 color = texture2D(atlas, v_uv) * v_color;
87 vec4 shape = v_shape;108 vec4 shape = v_shape;
88 if (v_blur > 1e-6) {109 if (v_blur > 1e-6) {
89 color *= shadow(v_local, shape.xy, shape.z, v_blur);110 color *= shadow(v_local, shape.xy, shape.z, v_blur);
111 } else if (v_blur < -0.5) {
112 color *= clamp(0.5 - taper(v_local, shape.x, shape.y, shape.z), 0.0, 1.0);
90 } else if (shape.x > 0.0 && shape.y > 0.0) {113 } else if (shape.x > 0.0 && shape.y > 0.0) {
91 vec2 q = abs(v_local) - shape.xy + shape.zw;114 vec2 q = abs(v_local) - shape.xy + shape.zw;
92 float distance;115 float distance;
crates/draw/src/draw.wgsl+23
...@@ -67,11 +67,34 @@ fn shadow(p: vec2<f32>, half: vec2<f32>, corner: f32, sigma: f32) -> f32 {...@@ -67,11 +67,34 @@ fn shadow(p: vec2<f32>, half: vec2<f32>, corner: f32, sigma: f32) -> f32 {
67 return value;67 return value;
68}68}
6969
70// Distance from `p` to the tapered capsule from (-h, 0) to (h, 0) with round ends of radii
71// `r0` and `r1` (Inigo Quilez's uneven capsule).
72fn taper(p: vec2<f32>, h: f32, r0: f32, r1: f32) -> f32 {
73 let q = vec2<f32>(abs(p.y), p.x + h);
74 let span = 2.0 * h;
75 let b = (r0 - r1) / max(span, 1e-6);
76 let far = length(q - vec2<f32>(0.0, span)) - r1;
77 if abs(b) >= 1.0 {
78 return min(length(q) - r0, far);
79 }
80 let a = sqrt(1.0 - b * b);
81 let k = dot(q, vec2<f32>(-b, a));
82 if k < 0.0 {
83 return length(q) - r0;
84 }
85 if k > a * span {
86 return far;
87 }
88 return dot(q, vec2<f32>(a, b)) - r0;
89}
90
70@fragment91@fragment
71fn fragment(input: Vertex) -> @location(0) vec4<f32> {92fn fragment(input: Vertex) -> @location(0) vec4<f32> {
72 var color = textureSample(atlas, atlas_sampler, input.uv) * input.color;93 var color = textureSample(atlas, atlas_sampler, input.uv) * input.color;
73 if input.blur > 0.0 {94 if input.blur > 0.0 {
74 color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur);95 color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur);
96 } else if input.blur < 0.0 {
97 color *= clamp(0.5 - taper(input.local, input.shape.x, input.shape.y, input.shape.z), 0.0, 1.0);
75 } else if input.shape.x > 0.0 && input.shape.y > 0.0 {98 } else if input.shape.x > 0.0 && input.shape.y > 0.0 {
76 let q = abs(input.local) - input.shape.xy + input.shape.zw;99 let q = abs(input.local) - input.shape.xy + input.shape.zw;
77 var distance: f32;100 var distance: f32;
crates/draw/src/edit.rs+1
...@@ -32,6 +32,7 @@ pub enum NamedKey {...@@ -32,6 +32,7 @@ pub enum NamedKey {
32 End,32 End,
33 PageUp,33 PageUp,
34 PageDown,34 PageDown,
35 F7,
35 F11,36 F11,
36 /// Shift, Control, Option or Command pressed alone.37 /// Shift, Control, Option or Command pressed alone.
37 Modifier,38 Modifier,
crates/draw/src/render.rs+39-10
...@@ -13,7 +13,7 @@ use gl as backend;...@@ -13,7 +13,7 @@ use gl as backend;
13use webgpu as backend;13use webgpu as backend;
1414
15pub use backend::Target;15pub use backend::Target;
16pub use icon::Palette;16pub use icon::{Palette, picture_icon};
17pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};17pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
18#[cfg(feature = "wgpu")]18#[cfg(feature = "wgpu")]
19pub use translucent::Translucent;19pub use translucent::Translucent;
...@@ -205,7 +205,8 @@ struct Vertex {...@@ -205,7 +205,8 @@ struct Vertex {
205 /// that rectangle's half size and corner radius; a zero half size paints everywhere.205 /// that rectangle's half size and corner radius; a zero half size paints everywhere.
206 clip_local: [f32; 2],206 clip_local: [f32; 2],
207 clip: [f32; 3],207 clip: [f32; 3],
208 /// The standard deviation, in device pixels, of the blur that makes `shape` a shadow.208 /// The standard deviation, in device pixels, of the blur that makes `shape` a shadow;
209 /// negative, `shape` is a tapered capsule's half length and end radii.
209 blur: f32,210 blur: f32,
210}211}
211212
...@@ -429,6 +430,14 @@ pub enum Primitive<'a> {...@@ -429,6 +430,14 @@ pub enum Primitive<'a> {
429 round: bool,430 round: bool,
430 color: [f32; 4],431 color: [f32; 4],
431 },432 },
433 /// A round pen's stroke between two points, `widths` units across at each, joined by the
434 /// lines touching both ends, as a pressure pen's width changes along a stroke.
435 Taper {
436 from: [f32; 2],
437 to: [f32; 2],
438 widths: [f32; 2],
439 color: [f32; 4],
440 },
432 /// A highlighter's stroke between two points, `width` units across with square ends:441 /// 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.442 /// its opaque `color` multiplies what lies beneath, so text under it stays dark.
434 Highlight {443 Highlight {
...@@ -860,7 +869,13 @@ impl Renderer {...@@ -860,7 +869,13 @@ impl Renderer {
860 width,869 width,
861 round,870 round,
862 color,871 color,
863 } => self.segment(space, *from, *to, *width, *round, *color)?,872 } => self.segment(space, *from, *to, [*width; 2], *round, *color)?,
873 Primitive::Taper {
874 from,
875 to,
876 widths,
877 color,
878 } => self.segment(space, *from, *to, *widths, true, *color)?,
864 Primitive::Highlight {879 Primitive::Highlight {
865 from,880 from,
866 to,881 to,
...@@ -868,7 +883,7 @@ impl Renderer {...@@ -868,7 +883,7 @@ impl Renderer {
868 color,883 color,
869 } => {884 } => {
870 blend = Blend::Multiply;885 blend = Blend::Multiply;
871 self.segment(space, *from, *to, *width, false, *color)?;886 self.segment(space, *from, *to, [*width; 2], false, *color)?;
872 }887 }
873 Primitive::Image { image, rect } => {888 Primitive::Image { image, rect } => {
874 if let Some(rect) = space.visible_rect(*rect)? {889 if let Some(rect) = space.visible_rect(*rect)? {
...@@ -1348,13 +1363,14 @@ impl Renderer {...@@ -1348,13 +1363,14 @@ impl Renderer {
1348 Ok(())1363 Ok(())
1349 }1364 }
13501365
1351 /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance.1366 /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance;
1367 /// ends of two widths make it a tapered capsule, which the shader marks by a negative blur.
1352 fn segment(1368 fn segment(
1353 &mut self,1369 &mut self,
1354 space: Space,1370 space: Space,
1355 from: [f32; 2],1371 from: [f32; 2],
1356 to: [f32; 2],1372 to: [f32; 2],
1357 width: f32,1373 widths: [f32; 2],
1358 round: bool,1374 round: bool,
1359 color: [f32; 4],1375 color: [f32; 4],
1360 ) -> Result<(), RenderError> {1376 ) -> Result<(), RenderError> {
...@@ -1366,8 +1382,15 @@ impl Renderer {...@@ -1366,8 +1382,15 @@ impl Renderer {
1366 };1382 };
1367 let [from, to] = [pixel(from), pixel(to)];1383 let [from, to] = [pixel(from), pixel(to)];
1368 // Hairlines stay one device pixel wide.1384 // Hairlines stay one device pixel wide.
1369 let radius = (width * space.scale).max(1.0) * 0.5;1385 let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5);
1370 if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() {1386 let radius = radii[0].max(radii[1]);
1387 if from
1388 .iter()
1389 .chain(&to)
1390 .chain(&color)
1391 .chain(&radii)
1392 .any(|v| !v.is_finite())
1393 {
1371 return Err(RenderError::InvalidPrimitive);1394 return Err(RenderError::InvalidPrimitive);
1372 }1395 }
1373 let pad = radius + 1.0;1396 let pad = radius + 1.0;
...@@ -1392,6 +1415,12 @@ impl Renderer {...@@ -1392,6 +1415,12 @@ impl Renderer {
1392 let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5];1415 let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5];
1393 let half = [length * 0.5 + radius, radius];1416 let half = [length * 0.5 + radius, radius];
1394 let corner = if round { radius } else { 0.0 };1417 let corner = if round { radius } else { 0.0 };
1418 let tapered = radii[0] != radii[1];
1419 let (shape, blur) = if tapered {
1420 ([length * 0.5, radii[0], radii[1], 0.0], -1.0)
1421 } else {
1422 ([half[0], half[1], corner, corner], 0.0)
1423 };
1395 let [hx, hy] = [half[0] + 1.0, half[1] + 1.0];1424 let [hx, hy] = [half[0] + 1.0, half[1] + 1.0];
1396 for local in [1425 for local in [
1397 [-hx, -hy],1426 [-hx, -hy],
...@@ -1411,11 +1440,11 @@ impl Renderer {...@@ -1411,11 +1440,11 @@ impl Renderer {
1411 uv: [0.5 / self.atlas_side() as f32; 2],1440 uv: [0.5 / self.atlas_side() as f32; 2],
1412 color,1441 color,
1413 local,1442 local,
1414 shape: [half[0], half[1], corner, corner],1443 shape,
1415 stroke: 0.0,1444 stroke: 0.0,
1416 clip_local: [0.0; 2],1445 clip_local: [0.0; 2],
1417 clip: [0.0; 3],1446 clip: [0.0; 3],
1418 blur: 0.0,1447 blur,
1419 });1448 });
1420 }1449 }
1421 Ok(())1450 Ok(())
crates/draw/src/render/icon.rs+91-1
...@@ -35,7 +35,8 @@ impl Palette {...@@ -35,7 +35,8 @@ impl Palette {
35/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round35/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round
36/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,36/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,
37/// saturation scale and lightness shift that carry the mean of the slot's colours in a37/// saturation scale and lightness shift that carry the mean of the slot's colours in a
38/// source onto the palette's colour apply to each of them.38/// source onto the palette's colour apply to each of them. A source's `image`s of PNG
39/// `data:` URLs, as `picture_icon` writes them, paint beneath its paths.
39pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image {40pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image {
40 let side = size;41 let side = size;
41 let size = size as f32;42 let size = size as f32;
...@@ -43,6 +44,9 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa...@@ -43,6 +44,9 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa
43 for source in sources {44 for source in sources {
44 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");45 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");
45 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));46 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));
47 for image in svg.descendants().filter(|node| node.has_tag_name("image")) {
48 picture(image, side, &mut pixels);
49 }
46 let paint = |value: &str| -> Option<Paint> {50 let paint = |value: &str| -> Option<Paint> {
47 match value {51 match value {
48 "none" => None,52 "none" => None,
...@@ -174,6 +178,61 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa...@@ -174,6 +178,61 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa
174 }178 }
175}179}
176180
181/// An icon source drawing the PNG `png` centred in the icon at its own proportions; none
182/// where it is not a picture.
183pub fn picture_icon(png: &[u8]) -> Option<String> {
184 use base64::Engine;
185 let [width, height] = super::RasterImage::measure(png)
186 .ok()?
187 .map(|side| side as f32);
188 let scale = 16.0 / width.max(height);
189 let [width, height] = [width * scale, height * scale];
190 Some(format!(
191 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><image x="{}" y="{}" width="{width}" height="{height}" href="data:image/png;base64,{}"/></svg>"#,
192 (16.0 - width) / 2.0,
193 (16.0 - height) / 2.0,
194 base64::engine::general_purpose::STANDARD.encode(png),
195 ))
196}
197
198/// Paints `node`, a picture of `picture_icon`'s, over `pixels`, an icon `side` pixels
199/// square, filtered to the whole pixels its box covers.
200fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) {
201 use base64::Engine;
202 let scale = side as f32 / 16.0;
203 let at = |name| {
204 let value: f32 = node.attribute(name).unwrap().parse().unwrap();
205 value * scale
206 };
207 let [x, y] = [at("x"), at("y")];
208 let [left, top, right, bottom] = [x, y, x + at("width"), y + at("height")]
209 .map(|edge| edge.round().clamp(0.0, side as f32) as u32);
210 let decoded = node
211 .attribute("href")
212 .and_then(|href| href.strip_prefix("data:image/png;base64,"))
213 .and_then(|data| base64::engine::general_purpose::STANDARD.decode(data).ok())
214 .and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok());
215 let Some(decoded) = decoded.filter(|_| right > left && bottom > top) else {
216 return;
217 };
218 let shown = image::imageops::resize(
219 &decoded.into_rgba8(),
220 right - left,
221 bottom - top,
222 image::imageops::FilterType::Triangle,
223 );
224 for (column, row, color) in shown.enumerate_pixels() {
225 let pixel = &mut pixels[((top + row) * side + left + column) as usize];
226 let [r, g, b, a] = color.0;
227 let alpha = f32::from(a) / 255.0;
228 let color = super::srgb(r, g, b);
229 for channel in 0..3 {
230 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
231 }
232 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
233 }
234}
235
177fn hex(value: &str) -> [f32; 4] {236fn hex(value: &str) -> [f32; 4] {
178 let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16)237 let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16)
179 .unwrap()238 .unwrap()
...@@ -550,6 +609,37 @@ mod tests {...@@ -550,6 +609,37 @@ mod tests {
550 assert_eq!(alpha(1, 8), 64);609 assert_eq!(alpha(1, 8), 64);
551 }610 }
552611
612 /// A 2×1 PNG, red then half-transparent blue, fits the icon's width, centred, and paints
613 /// beneath the paths of later sources.
614 #[test]
615 fn pictures_fill_their_box_at_their_proportions() {
616 let mut png = Vec::new();
617 let mut encoder = png::Encoder::new(&mut png, 2, 1);
618 encoder.set_color(png::ColorType::Rgba);
619 encoder
620 .write_header()
621 .unwrap()
622 .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 128])
623 .unwrap();
624 let source = picture_icon(&png).unwrap();
625 let image = rasterize(&[&source, MARK], 32, [1.0; 3], &Palette::default());
626 let pixel = |x: usize, y: usize| &image.data[(y * 32 + x) * 4..][..4];
627 // The picture spans rows 8 to 24 of 32.
628 assert_eq!(pixel(2, 4)[3], 0);
629 assert_eq!(pixel(2, 12), [255, 0, 0, 255]);
630 assert_eq!(pixel(30, 20), [0, 0, 255, 128]);
631 assert_eq!(pixel(30, 28)[3], 0);
632 let marked = rasterize(&[MARK], 32, [1.0; 3], &Palette::default());
633 let covered: Vec<_> = (0..32 * 32)
634 .filter(|at| marked.data[at * 4 + 3] == 255)
635 .collect();
636 assert!(!covered.is_empty());
637 for at in covered {
638 assert_eq!(image.data[at * 4..][..4], marked.data[at * 4..][..4]);
639 }
640 assert!(picture_icon(b"not a picture").is_none());
641 }
642
553 #[test]643 #[test]
554 fn icons_keep_transparency_and_overlays_across_scales() {644 fn icons_keep_transparency_and_overlays_across_scales() {
555 for size in [8, 16, 32, 57] {645 for size in [8, 16, 32, 57] {
crates/mobile/Cargo.toml+4
...@@ -20,5 +20,9 @@ resvg = { version = "0.48.1", default-features = false }...@@ -20,5 +20,9 @@ resvg = { version = "0.48.1", default-features = false }
20serde = { version = "1", features = ["derive"] }20serde = { version = "1", features = ["derive"] }
21serde_json = "1"21serde_json = "1"
2222
23[target.'cfg(target_os = "ios")'.dependencies]
24objc2 = "0.5.2"
25objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSRange", "NSString", "NSThread"] }
26
23[dev-dependencies]27[dev-dependencies]
24tempfile = "3"28tempfile = "3"
crates/mobile/include/snowbound.h+10
...@@ -15,6 +15,9 @@ void sb_string_free(char *text);...@@ -15,6 +15,9 @@ void sb_string_free(char *text);
15typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);15typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);
16void sb_set_coordinator(sb_coordinator coordinator);16void sb_set_coordinator(sb_coordinator coordinator);
17void sb_set_sync_wake(void (*wake)(void));17void sb_set_sync_wake(void (*wake)(void));
18typedef void (*sb_versions)(const char *path, void (*found)(void *, const char *id, const char *device), void *context);
19typedef bool (*sb_retire)(const char *path, const char *id, bool keep);
20void sb_set_versions(sb_versions versions, sb_retire retire);
18char *sb_tags(void);21char *sb_tags(void);
19char *sb_pens(uint32_t section);22char *sb_pens(uint32_t section);
20bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);23bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
...@@ -22,6 +25,9 @@ bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);...@@ -22,6 +25,9 @@ bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
22const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);25const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
23const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,26const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
24 const char *domain, const char *root, const char *cache, char **error);27 const char *domain, const char *root, const char *cache, char **error);
28bool sb_notebook_create(const char *path, const char *cache, const char *author, const char *date, const char *time,
29 char **error);
30bool sb_notebook_moved(const char *cache, const char *from, const char *to, char **error);
25char *sb_library_sections(const Library *library);31char *sb_library_sections(const Library *library);
26char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,32char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
27 const char *date, const char *time);33 const char *date, const char *time);
...@@ -31,6 +37,7 @@ char *sb_library_sync_status(const Library *library);...@@ -31,6 +37,7 @@ char *sb_library_sync_status(const Library *library);
31void sb_library_set_offline(const Library *library, bool offline);37void sb_library_set_offline(const Library *library, bool offline);
32void sb_library_touched(const Library *library, const char *path);38void sb_library_touched(const Library *library, const char *path);
33void sb_library_sync_now(const Library *library);39void sb_library_sync_now(const Library *library);
40bool sb_library_close(const Library *library, double seconds, char **error);
34void sb_library_free(const Library *library);41void sb_library_free(const Library *library);
3542
36Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password,43Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password,
...@@ -62,6 +69,7 @@ void sb_view_content(View *view, float bounds[4]);...@@ -62,6 +69,7 @@ void sb_view_content(View *view, float bounds[4]);
62bool sb_view_block(const View *view, float x, float y, float rect[4]);69bool sb_view_block(const View *view, float x, float y, float rect[4]);
63void sb_view_set_transform(View *view, float zoom, float x, float y);70void sb_view_set_transform(View *view, float zoom, float x, float y);
64uint8_t sb_view_target(const View *view, float x, float y);71uint8_t sb_view_target(const View *view, float x, float y);
72void sb_view_pressure(View *view, float pressure);
65bool sb_view_press(View *view, float x, float y);73bool sb_view_press(View *view, float x, float y);
66bool sb_view_drag(View *view, float x, float y);74bool sb_view_drag(View *view, float x, float y);
67bool sb_view_release(View *view);75bool sb_view_release(View *view);
...@@ -69,6 +77,8 @@ bool sb_view_undo(View *view, bool redo);...@@ -69,6 +77,8 @@ bool sb_view_undo(View *view, bool redo);
69bool sb_view_can_undo(const View *view, bool redo);77bool sb_view_can_undo(const View *view, bool redo);
70uint64_t sb_view_format(const View *view);78uint64_t sb_view_format(const View *view);
71bool sb_view_apply(View *view, uint8_t command);79bool sb_view_apply(View *view, uint8_t command);
80char *sb_view_correction(const View *view);
81bool sb_view_correct(View *view, uint8_t command, const char *text);
72char *sb_view_title(const View *view);82char *sb_view_title(const View *view);
73bool sb_view_focus_title(View *view);83bool sb_view_focus_title(View *view);
74bool sb_view_find(View *view, const char *query);84bool sb_view_find(View *view, const char *query);
crates/mobile/src/lib.rs+79-2
...@@ -8,6 +8,8 @@...@@ -8,6 +8,8 @@
8//! the host redraws and rereads `sb_view_content`.8//! the host redraws and rereads `sb_view_content`.
99
10mod library;10mod library;
11#[cfg(target_os = "ios")]
12mod spell;
1113
12use library::Shared;14use library::Shared;
13pub use library::{Library, Section, Share};15pub use library::{Library, Section, Share};
...@@ -512,8 +514,10 @@ impl Canvas {...@@ -512,8 +514,10 @@ impl Canvas {
512 },514 },
513 size: Some(size),515 size: Some(size),
514 bytes: Some(bytes.into()),516 bytes: Some(bytes.into()),
517 display: None,
515 alt: None,518 alt: None,
516 background: false,519 background: false,
520 printout: None,
517 };521 };
518 let page = &mut self.page;522 let page = &mut self.page;
519 page.editor.insert_picture(&mut page.engine, image)?;523 page.editor.insert_picture(&mut page.engine, image)?;
...@@ -681,11 +685,21 @@ impl View {...@@ -681,11 +685,21 @@ impl View {
681 .ok_or("No supported surface configuration")?;685 .ok_or("No supported surface configuration")?;
682 surface.configure(&gpu.renderer.device, &config);686 surface.configure(&gpu.renderer.device, &config);
683 let (page, read_only) = section.shared.page(space)?;687 let (page, read_only) = section.shared.page(space)?;
688 let frame = Arc::<Frame>::default();
689 #[cfg_attr(not(target_os = "ios"), expect(unused_mut))]
690 let mut canvas = Canvas::new(space, page, pixels, scale)?;
691 #[cfg(target_os = "ios")]
692 {
693 canvas.page.spelling = Some(canvas::spelling::Spelling::new(
694 spell::dictionary(),
695 Waker::from(frame.clone()),
696 ));
697 }
684 Ok(Self {698 Ok(Self {
685 canvas: ManuallyDrop::new(Canvas::new(space, page, pixels, scale)?),699 canvas: ManuallyDrop::new(canvas),
686 surface,700 surface,
687 config,701 config,
688 frame: Arc::default(),702 frame,
689 section: Arc::clone(&section.shared),703 section: Arc::clone(&section.shared),
690 read_only,704 read_only,
691 })705 })
...@@ -916,6 +930,15 @@ pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {...@@ -916,6 +930,15 @@ pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
916 view.stored(result)930 view.stored(result)
917}931}
918932
933/// The Pencil's pressure at the touch's next point, from 0 to 1; below 0 for a finger, whose
934/// strokes keep their pen's width.
935#[unsafe(no_mangle)]
936pub extern "C" fn sb_view_pressure(view: &mut View, pressure: f32) {
937 view.canvas
938 .page
939 .set_pressure((pressure >= 0.0).then_some(pressure));
940}
941
919#[unsafe(no_mangle)]942#[unsafe(no_mangle)]
920pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {943pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {
921 report(view.canvas.drag([x, y])).unwrap_or(false)944 report(view.canvas.drag([x, y])).unwrap_or(false)
...@@ -960,6 +983,60 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {...@@ -960,6 +983,60 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
960 view.stored(result)983 view.stored(result)
961}984}
962985
986/// The marked word at the caret, or selected, as JSON `{"word", "suggestions",
987/// "repeated"}`; null where there is none.
988#[unsafe(no_mangle)]
989pub extern "C" fn sb_view_correction(view: &View) -> *mut c_char {
990 #[derive(serde::Serialize)]
991 struct Shown {
992 word: String,
993 suggestions: Vec<String>,
994 repeated: bool,
995 }
996 view.canvas
997 .page
998 .selected_correction()
999 .map_or(std::ptr::null_mut(), |correction| {
1000 library::json(Ok(Shown {
1001 word: correction.word,
1002 suggestions: correction.suggestions,
1003 repeated: correction.repeated,
1004 }))
1005 })
1006}
1007
1008/// Acts on the word `sb_view_correction` names: 0 replaces it with `text`, empty to delete a
1009/// repeated word; 1 ignores it; 2 adds it to the dictionary. True where the text changed.
1010///
1011/// # Safety
1012/// `text` is NUL-terminated UTF-8, or null for 1 and 2.
1013#[unsafe(no_mangle)]
1014pub unsafe extern "C" fn sb_view_correct(
1015 view: &mut View,
1016 command: u8,
1017 text: *const c_char,
1018) -> bool {
1019 let page = &mut view.canvas.page;
1020 let (Some(correction), Some(spelling)) = (page.selected_correction(), page.spelling.clone())
1021 else {
1022 return false;
1023 };
1024 match command {
1025 0 => {
1026 let result = page.correct(&correction, &string(text));
1027 view.stored(result.map(moved))
1028 }
1029 1 => {
1030 spelling.ignore(&correction.word);
1031 false
1032 }
1033 _ => {
1034 spelling.learn(&correction.word);
1035 false
1036 }
1037 }
1038}
1039
963/// The page title's text; null on a page without an editable title.1040/// The page title's text; null on a page without an editable title.
964#[unsafe(no_mangle)]1041#[unsafe(no_mangle)]
965pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {1042pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {
crates/mobile/src/library.rs+248-10
...@@ -6,7 +6,7 @@ use crate::{Result, owned, report, string};...@@ -6,7 +6,7 @@ use crate::{Result, owned, report, string};
6use canvas::search::{Entry, Index, Query, Tagged};6use canvas::search::{Entry, Index, Query, Tagged};
7use notebook::{7use notebook::{
8 Remote, Replica,8 Remote, Replica,
9 discover::{Folder, SectionState},9 discover::{Folder, Reason, SectionState},
10 session::{self, Background, Event, Known, Notebook, SyncStatus},10 session::{self, Background, Event, Known, Notebook, SyncStatus},
11 smb::{Client, Credentials},11 smb::{Client, Credentials},
12};12};
...@@ -87,6 +87,28 @@ pub(crate) fn coordinated<F: FnOnce() -> T, T>(path: &Path, write: bool, work: F...@@ -87,6 +87,28 @@ pub(crate) fn coordinated<F: FnOnce() -> T, T>(path: &Path, write: bool, work: F
87 .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again."))87 .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again."))
88}88}
8989
90/// Calls `found(context, id, device)` for each conflict version the host's file provider keeps
91/// beside the file at `path`, as iCloud Drive keeps another device's commit that lost:
92/// `id` is a file the version's contents read from, `device` who saved it or null.
93pub type Versions = extern "C" fn(
94 path: *const c_char,
95 found: extern "C" fn(*mut c_void, *const c_char, *const c_char),
96 context: *mut c_void,
97);
98
99/// Marks version `id` of the file at `path` resolved and removes it, first keeping a copy
100/// beside the file when `keep`; false when it could not.
101pub type Retire = extern "C" fn(path: *const c_char, id: *const c_char, keep: bool) -> bool;
102
103static VERSIONS: OnceLock<(Versions, Retire)> = OnceLock::new();
104
105/// Merges the conflict versions the host lists into their files from now on, then has the
106/// host retire them; set once, before any library opens.
107#[unsafe(no_mangle)]
108pub extern "C" fn sb_set_versions(versions: Versions, retire: Retire) {
109 let _ = VERSIONS.set((versions, retire));
110}
111
90/// A local section file read and published under the host's file coordination.112/// A local section file read and published under the host's file coordination.
91struct Coordinated(PathBuf);113struct Coordinated(PathBuf);
92114
...@@ -116,6 +138,42 @@ impl Remote for Coordinated {...@@ -116,6 +138,42 @@ impl Remote for Coordinated {
116 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {138 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
117 coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)?139 coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)?
118 }140 }
141
142 fn versions(&mut self) -> io::Result<Vec<notebook::Version>> {
143 let Some((versions, _)) = VERSIONS.get() else {
144 return Ok(Vec::new());
145 };
146 extern "C" fn found(context: *mut c_void, id: *const c_char, device: *const c_char) {
147 // SAFETY: `context` is the list below, alive for the host's call.
148 let listed = unsafe { &mut *context.cast::<Vec<notebook::Version>>() };
149 listed.push(notebook::Version {
150 id: string(id),
151 device: (!device.is_null()).then(|| string(device)),
152 });
153 }
154 let mut listed: Vec<notebook::Version> = Vec::new();
155 let path = CString::new(self.0.as_os_str().as_encoded_bytes())?;
156 versions(path.as_ptr(), found, (&raw mut listed).cast());
157 Ok(listed)
158 }
159
160 /// A version's contents never change: they read without coordination.
161 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
162 std::fs::read(id)
163 }
164
165 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
166 let Some((_, retire)) = VERSIONS.get() else {
167 return Ok(());
168 };
169 let path = CString::new(self.0.as_os_str().as_encoded_bytes())?;
170 let id = CString::new(id)?;
171 if retire(path.as_ptr(), id.as_ptr(), keep) {
172 Ok(())
173 } else {
174 Err(io::Error::other("The version could not be retired"))
175 }
176 }
119}177}
120178
121fn uncommitted(error: io::Error) -> CommitError {179fn uncommitted(error: io::Error) -> CommitError {
...@@ -169,7 +227,7 @@ enum Place {...@@ -169,7 +227,7 @@ enum Place {
169#[derive(serde::Serialize, serde::Deserialize)]227#[derive(serde::Serialize, serde::Deserialize)]
170struct Listed {228struct Listed {
171 tabs: Vec<Tab>,229 tabs: Vec<Tab>,
172 /// Each section's file identity in hex, which names its replica, by catalog path.230 /// Each readable section's file identity in hex, which names its replica, by catalog path.
173 files: BTreeMap<String, String>,231 files: BTreeMap<String, String>,
174}232}
175233
...@@ -177,10 +235,14 @@ fn hex(bytes: &[u8]) -> String {...@@ -177,10 +235,14 @@ fn hex(bytes: &[u8]) -> String {
177 bytes.iter().map(|byte| format!("{byte:02x}")).collect()235 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
178}236}
179237
180/// Every section's file identity in `folder` and its groups, by catalog path.238/// Every readable section's file identity in `folder` and its groups, by catalog path, as
181fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {239/// `Notebook::replicas` lists a local notebook's. `Client::read` answers a protected section
240/// as it does one mid-write (WouldBlock), so syncing one would show it in use for good.
241pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {
182 for section in &folder.sections {242 for section in &folder.sections {
183 files.insert(section.path.clone(), hex(&section.file_id));243 if matches!(section.state, SectionState::Readable { .. }) {
244 files.insert(section.path.clone(), hex(&section.file_id));
245 }
184 }246 }
185 for group in &folder.groups {247 for group in &folder.groups {
186 self::files(group, files);248 self::files(group, files);
...@@ -205,8 +267,15 @@ pub struct Library {...@@ -205,8 +267,15 @@ pub struct Library {
205 open: Mutex<Vec<(String, Weak<Shared>)>>,267 open: Mutex<Vec<(String, Weak<Shared>)>>,
206 /// Work Offline, which sections opened later follow too.268 /// Work Offline, which sections opened later follow too.
207 offline: AtomicBool,269 offline: AtomicBool,
270 /// How long edits wait for a pause in typing before they publish (`Section::set_pause`):
271 /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent
272 /// with another device's a conflict version.
273 pause: Duration,
208}274}
209275
276/// The pause edits to a file a provider keeps elsewhere wait for.
277const PAUSE: Duration = Duration::from_secs(3);
278
210#[derive(serde::Serialize, serde::Deserialize)]279#[derive(serde::Serialize, serde::Deserialize)]
211pub(crate) struct Tab {280pub(crate) struct Tab {
212 pub(crate) name: String,281 pub(crate) name: String,
...@@ -218,6 +287,15 @@ pub(crate) struct Tab {...@@ -218,6 +287,15 @@ pub(crate) struct Tab {
218 pub(crate) color: [u8; 3],287 pub(crate) color: [u8; 3],
219 /// Password-protected or unreadable sections list but do not open.288 /// Password-protected or unreadable sections list but do not open.
220 pub(crate) readable: bool,289 pub(crate) readable: bool,
290 /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it.
291 #[serde(default)]
292 pub(crate) downloading: bool,
293 /// The name of the section this file copies, which opens instead.
294 #[serde(default)]
295 pub(crate) copy: Option<String>,
296 /// Why the file could not be read, where retrying or repair may help.
297 #[serde(default)]
298 pub(crate) problem: Option<String>,
221}299}
222300
223fn rgb(colorref: u32) -> [u8; 3] {301fn rgb(colorref: u32) -> [u8; 3] {
...@@ -246,6 +324,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -246,6 +324,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
246 group: folder.path.clone(),324 group: folder.path.clone(),
247 color: rgb(color.unwrap_or(SECTION_COLOR)),325 color: rgb(color.unwrap_or(SECTION_COLOR)),
248 readable,326 readable,
327 downloading: false,
328 copy: None,
329 problem: None,
249 });330 });
250 }331 }
251 for entry in folder.unavailable.iter().filter(|entry| !entry.group) {332 for entry in folder.unavailable.iter().filter(|entry| !entry.group) {
...@@ -255,6 +336,16 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -255,6 +336,16 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
255 group: folder.path.clone(),336 group: folder.path.clone(),
256 color: rgb(SECTION_COLOR),337 color: rgb(SECTION_COLOR),
257 readable: false,338 readable: false,
339 downloading: entry.reason == Reason::Evicted,
340 copy: match &entry.reason {
341 Reason::Copy { of } => Some(stem(of)),
342 _ => None,
343 },
344 problem: match entry.reason {
345 Reason::InUse => Some("Section in use".into()),
346 Reason::Unreadable => Some("Can’t read this section".into()),
347 _ => None,
348 },
258 });349 });
259 }350 }
260 for group in &folder.groups {351 for group in &folder.groups {
...@@ -294,6 +385,7 @@ impl Library {...@@ -294,6 +385,7 @@ impl Library {
294 background,385 background,
295 open: Mutex::default(),386 open: Mutex::default(),
296 offline: AtomicBool::new(false),387 offline: AtomicBool::new(false),
388 pause: if local { Duration::ZERO } else { PAUSE },
297 })389 })
298 }390 }
299391
...@@ -315,6 +407,7 @@ impl Library {...@@ -315,6 +407,7 @@ impl Library {
315 background: Some(background),407 background: Some(background),
316 open: Mutex::default(),408 open: Mutex::default(),
317 offline: AtomicBool::new(false),409 offline: AtomicBool::new(false),
410 pause: Duration::ZERO,
318 };411 };
319 let reached = library.with_notebook(false, |_| Ok(()));412 let reached = library.with_notebook(false, |_| Ok(()));
320 match library.listed() {413 match library.listed() {
...@@ -326,16 +419,15 @@ impl Library {...@@ -326,16 +419,15 @@ impl Library {
326419
327 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.420 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.
328 fn watch(&self, files: &BTreeMap<String, String>) {421 fn watch(&self, files: &BTreeMap<String, String>) {
329 let Some(background) = &self.background else {422 let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else {
330 return;423 return;
331 };424 };
332 let smb = self.cache.join("smb");
333 background.watch(425 background.watch(
334 files426 files
335 .iter()427 .iter()
336 .map(|(path, identity)| Known {428 .map(|(path, identity)| Known {
337 path: path.clone(),429 path: path.clone(),
338 replica: Some(smb.join(format!("{identity}.sqlite"))),430 replica: Some(replicas.join(format!("{identity}.sqlite"))),
339 found: None,431 found: None,
340 image: None,432 image: None,
341 })433 })
...@@ -343,6 +435,15 @@ impl Library {...@@ -343,6 +435,15 @@ impl Library {
343 );435 );
344 }436 }
345437
438 /// The cache folder of a share notebook's replicas.
439 fn share_replicas(&self) -> Option<PathBuf> {
440 let Place::Share { server, root, .. } = &self.place else {
441 return None;
442 };
443 let location = notebook::location::smb(&server.address, &server.share, root);
444 Some(notebook::location::folder(&self.cache, &location))
445 }
446
346 /// Where a share notebook's last listing is kept.447 /// Where a share notebook's last listing is kept.
347 fn listing(&self) -> Option<PathBuf> {448 fn listing(&self) -> Option<PathBuf> {
348 let Place::Share { server, root, .. } = &self.place else {449 let Place::Share { server, root, .. } = &self.place else {
...@@ -418,6 +519,9 @@ impl Library {...@@ -418,6 +519,9 @@ impl Library {
418 group: String::new(),519 group: String::new(),
419 color: rgb(SECTION_COLOR),520 color: rgb(SECTION_COLOR),
420 readable: true,521 readable: true,
522 downloading: false,
523 copy: None,
524 problem: None,
421 }]);525 }]);
422 }526 }
423 let listed = self.with_notebook(false, |notebook| {527 let listed = self.with_notebook(false, |notebook| {
...@@ -472,6 +576,7 @@ impl Library {...@@ -472,6 +576,7 @@ impl Library {
472 }576 }
473 };577 };
474 section.set_offline(self.offline.load(Ordering::Relaxed));578 section.set_offline(self.offline.load(Ordering::Relaxed));
579 section.set_pause(self.pause);
475 if let Some(background) = &self.background {580 if let Some(background) = &self.background {
476 background.hold(path, &section);581 background.hold(path, &section);
477 }582 }
...@@ -505,13 +610,15 @@ impl Library {...@@ -505,13 +610,15 @@ impl Library {
505 .listed()610 .listed()
506 .and_then(|mut listed| listed.files.remove(path))611 .and_then(|mut listed| listed.files.remove(path))
507 .ok_or("The notebook hasn’t listed this section")?;612 .ok_or("The notebook hasn’t listed this section")?;
508 let cache = self.cache.join("smb").join(format!("{identity}.sqlite"));613 let replicas = self.share_replicas().ok_or("Not a share")?;
614 let cache = replicas.join(format!("{identity}.sqlite"));
509 let replica = if cache.exists() {615 let replica = if cache.exists() {
510 Replica::open(&cache)?616 Replica::open(&cache)?
511 } else {617 } else {
512 let client = self618 let client = self
513 .client()619 .client()
514 .ok_or("The server can’t be reached, and this section hasn’t been opened here before.")?;620 .ok_or("The server can’t be reached, and this section hasn’t been opened here before.")?;
621 std::fs::create_dir_all(&replicas)?;
515 Replica::create(&cache, &client.read_storage(&file, LIMIT)?)?622 Replica::create(&cache, &client.read_storage(&file, LIMIT)?)?
516 };623 };
517 let server = Arc::clone(server);624 let server = Arc::clone(server);
...@@ -904,6 +1011,71 @@ impl Library {...@@ -904,6 +1011,71 @@ impl Library {
904 .collect()1011 .collect()
905 }1012 }
9061013
1014 /// Publishes every edit the notebook's replicas hold within `limit`, then lets go of the
1015 /// notebook as OneNote closes one: nothing syncs from then on, and each replica holding
1016 /// nothing unpublished is deleted once no session holds it. An error leaves it syncing.
1017 pub(crate) fn close(&self, limit: Duration) -> Result<()> {
1018 const WAITING: &str = "Some changes haven’t been saved to the notebook yet.";
1019 let deadline = Instant::now() + limit;
1020 let open = self.open_sections();
1021 for (_, shared) in &open {
1022 while !shared.section.pending()?.is_empty() {
1023 if Instant::now() >= deadline {
1024 return Err(WAITING.into());
1025 }
1026 shared.section.wake();
1027 std::thread::sleep(Duration::from_millis(50));
1028 }
1029 }
1030 let Some(background) = &self.background else {
1031 return Ok(());
1032 };
1033 let before: HashMap<String, Option<u64>> = background
1034 .status()
1035 .into_iter()
1036 .map(|(path, status)| (path, status.synced))
1037 .collect();
1038 // Sections without a replica hold nothing to publish.
1039 let replicas: Vec<&String> = {
1040 let notebook = self.notebook();
1041 before
1042 .keys()
1043 .filter(|path| !open.iter().any(|(held, _)| held == *path))
1044 .filter(|path| {
1045 notebook
1046 .as_ref()
1047 .and_then(|notebook| notebook.replica_path(path).ok())
1048 .is_some_and(|replica| replica.exists())
1049 })
1050 .collect()
1051 };
1052 loop {
1053 // Working offline, the background checks only when woken.
1054 background.wake();
1055 let status = background.status();
1056 let mut waiting = replicas.iter().filter_map(|path| {
1057 match status.iter().find(|(listed, _)| listed == *path) {
1058 Some((_, status))
1059 if status.queued == 0
1060 && status.error.is_none()
1061 && status.synced > before[*path] =>
1062 {
1063 None
1064 }
1065 Some((_, status)) => Some(status.error.as_ref().map(ToString::to_string)),
1066 None => Some(None),
1067 }
1068 });
1069 let Some(error) = waiting.next() else { break };
1070 if Instant::now() >= deadline {
1071 return Err(error.unwrap_or_else(|| WAITING.to_owned()).into());
1072 }
1073 std::thread::sleep(Duration::from_millis(50));
1074 }
1075 background.discard();
1076 Ok(())
1077 }
1078
907 fn open_sections(&self) -> Vec<(String, Arc<Shared>)> {1079 fn open_sections(&self) -> Vec<(String, Arc<Shared>)> {
908 let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner());1080 let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner());
909 open.retain(|(_, shared)| shared.strong_count() > 0);1081 open.retain(|(_, shared)| shared.strong_count() > 0);
...@@ -1091,8 +1263,56 @@ pub unsafe extern "C" fn sb_library_server(...@@ -1091,8 +1263,56 @@ pub unsafe extern "C" fn sb_library_server(
1091 }1263 }
1092}1264}
10931265
1266/// Creates the notebook folder `path` holding one section with one page titled with `date`
1267/// and `time`, as OneNote's New Notebook does; `sb_library_open` opens it. False, with
1268/// `error` set, when it cannot be made.
1269///
1270/// # Safety
1271/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1272#[unsafe(no_mangle)]
1273pub unsafe extern "C" fn sb_notebook_create(
1274 path: *const c_char,
1275 cache: *const c_char,
1276 author: *const c_char,
1277 date: *const c_char,
1278 time: *const c_char,
1279 error: *mut *mut c_char,
1280) -> bool {
1281 let created = dated(&string(author), &string(date), &string(time)).and_then(|page| {
1282 Notebook::create(string(path), string(cache), Notebook::NEW_COLOR, &page)?;
1283 Ok(())
1284 });
1285 created.map_err(|cause| failed(cause, error)).is_ok()
1286}
1287
1288/// Moves the replicas of the notebook folder or lone section file the app has just moved
1289/// from `from` to `to`, so that its edits waiting there still publish. False, with `error`
1290/// set, when they cannot be moved.
1291///
1292/// # Safety
1293/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1294#[unsafe(no_mangle)]
1295pub unsafe extern "C" fn sb_notebook_moved(
1296 cache: *const c_char,
1297 from: *const c_char,
1298 to: *const c_char,
1299 error: *mut *mut c_char,
1300) -> bool {
1301 let moved = || -> Result<()> {
1302 let from = notebook::location::local(Path::new(&string(from)))?;
1303 let to = notebook::location::local(Path::new(&string(to)))?;
1304 Ok(notebook::location::moved(
1305 Path::new(&string(cache)),
1306 &from,
1307 &to,
1308 )?)
1309 };
1310 moved().map_err(|cause| failed(cause, error)).is_ok()
1311}
1312
1094/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,1313/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,
1095/// `color` as sRGB bytes and `readable`, in the notebook's order; null if it cannot be read.1314/// `color` as sRGB bytes, `readable`, `downloading` and `copy`, in the notebook's order; null
1315/// if it cannot be read.
1096#[unsafe(no_mangle)]1316#[unsafe(no_mangle)]
1097pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {1317pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {
1098 json(library.tabs())1318 json(library.tabs())
...@@ -1202,6 +1422,24 @@ pub extern "C" fn sb_library_sync_now(library: &Library) {...@@ -1202,6 +1422,24 @@ pub extern "C" fn sb_library_sync_now(library: &Library) {
1202 }1422 }
1203}1423}
12041424
1425/// Publishes every edit the notebook holds within `seconds`, then lets go of it
1426/// (`Library::close`) so its folder can be moved or deleted: false, with `error` set, while
1427/// an edit is still unpublished, and the notebook syncs on.
1428///
1429/// # Safety
1430/// `error` is null or a place for a string.
1431#[unsafe(no_mangle)]
1432pub unsafe extern "C" fn sb_library_close(
1433 library: &Library,
1434 seconds: f64,
1435 error: *mut *mut c_char,
1436) -> bool {
1437 library
1438 .close(Duration::from_secs_f64(seconds))
1439 .map_err(|cause| failed(cause, error))
1440 .is_ok()
1441}
1442
1205/// # Safety1443/// # Safety
1206/// `library` came from `sb_library_open` or `sb_library_server` and is not used again.1444/// `library` came from `sb_library_open` or `sb_library_server` and is not used again.
1207#[unsafe(no_mangle)]1445#[unsafe(no_mangle)]
crates/mobile/src/spell.rs created+105
...@@ -0,0 +1,105 @@
1//! Spelling through UIKit's UITextChecker, which the spelling thread reaches on the main
2//! thread, as UIKit's objects expect.
3
4use canvas::spelling::{Dictionary, pick};
5use objc2::{class, msg_send, msg_send_id, rc::Retained, runtime::AnyObject};
6use objc2_foundation::{MainThreadMarker, NSArray, NSRange, NSString};
7use std::ffi::c_void;
8
9unsafe extern "C" {
10 /// The main queue; only its address matters.
11 static _dispatch_main_q: u8;
12 fn dispatch_sync_f(queue: *mut c_void, context: *mut c_void, work: extern "C" fn(*mut c_void));
13}
14
15/// Runs `work` on the main thread, waiting for it.
16fn on_main<F: FnOnce() -> R + Send, R: Send>(work: F) -> R {
17 if MainThreadMarker::new().is_some() {
18 return work();
19 }
20 extern "C" fn run<F: FnOnce() -> R, R>(context: *mut c_void) {
21 let (work, result) = unsafe { &mut *context.cast::<(Option<F>, Option<R>)>() };
22 *result = work.take().map(|work| work());
23 }
24 let mut slot = (Some(work), None);
25 unsafe {
26 dispatch_sync_f(
27 (&raw const _dispatch_main_q).cast_mut().cast(),
28 (&raw mut slot).cast(),
29 run::<F, R>,
30 );
31 }
32 slot.1.expect("the main queue ran the work")
33}
34
35fn checker() -> Retained<AnyObject> {
36 unsafe { msg_send_id![class!(UITextChecker), new] }
37}
38
39struct Checker {
40 /// The spelling languages the system has, as `en_US`.
41 languages: Vec<String>,
42}
43
44impl Dictionary for Checker {
45 fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> {
46 on_main(|| {
47 let checker = checker();
48 words
49 .iter()
50 .map(|(word, lcid)| {
51 let Some(language) = pick(*lcid, &self.languages) else {
52 return false;
53 };
54 let word = NSString::from_str(word);
55 let found: NSRange = unsafe {
56 msg_send![&checker,
57 rangeOfMisspelledWordInString: &*word,
58 range: NSRange::new(0, word.length()),
59 startingAt: 0isize,
60 wrap: false,
61 language: &*NSString::from_str(language)]
62 };
63 found.length > 0
64 })
65 .collect()
66 })
67 }
68
69 fn suggest(&self, word: &str, lcid: u32) -> Vec<String> {
70 on_main(|| {
71 let Some(language) = pick(lcid, &self.languages) else {
72 return Vec::new();
73 };
74 let word = NSString::from_str(word);
75 let guesses: Option<Retained<NSArray<NSString>>> = unsafe {
76 msg_send_id![&checker(),
77 guessesForWordRange: NSRange::new(0, word.length()),
78 inString: &*word,
79 language: &*NSString::from_str(language)]
80 };
81 guesses.map_or_else(Vec::new, |guesses| {
82 guesses.iter().map(|guess| guess.to_string()).collect()
83 })
84 })
85 }
86
87 fn learn(&self, word: &str) {
88 on_main(|| unsafe {
89 let _: () = msg_send![class!(UITextChecker), learnWord: &*NSString::from_str(word)];
90 });
91 }
92}
93
94/// UIKit's spell checker with the languages the system has.
95pub fn dictionary() -> Box<dyn Dictionary> {
96 let languages = on_main(|| {
97 let languages: Retained<NSArray<NSString>> =
98 unsafe { msg_send_id![class!(UITextChecker), availableLanguages] };
99 languages
100 .iter()
101 .map(|language| language.to_string())
102 .collect()
103 });
104 Box::new(Checker { languages })
105}
crates/mobile/src/tests.rs+130-7
...@@ -104,6 +104,35 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {...@@ -104,6 +104,35 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
104 assert_eq!(lone.tabs().unwrap().len(), 1);104 assert_eq!(lone.tabs().unwrap().len(), 1);
105}105}
106106
107#[test]
108fn a_new_notebook_opens_with_one_section_and_refuses_a_taken_name() {
109 let directory = tempfile::tempdir().unwrap();
110 let root = directory.path().join("Mine");
111 let cache = directory.path().join("cache");
112 std::fs::create_dir(&cache).unwrap();
113 let c = |path: &Path| CString::new(path.to_str().unwrap()).unwrap();
114 let create = |error: &mut *mut c_char| unsafe {
115 library::sb_notebook_create(
116 c(&root).as_ptr(),
117 c(&cache).as_ptr(),
118 c"Clover Test".as_ptr(),
119 c"Tuesday, September 29, 2026".as_ptr(),
120 c"9:41 AM".as_ptr(),
121 error,
122 )
123 };
124 let mut error = std::ptr::null_mut();
125 assert!(create(&mut error));
126 assert!(error.is_null());
127 let library = Library::open(&root, &cache, true).unwrap();
128 let tabs = library.tabs().unwrap();
129 assert_eq!(tabs.len(), 1);
130 assert!(tabs[0].readable);
131 assert!(!create(&mut error));
132 assert!(!error.is_null());
133 unsafe { sb_string_free(error) };
134}
135
107#[test]136#[test]
108fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {137fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
109 let (_directory, section) = features();138 let (_directory, section) = features();
...@@ -496,22 +525,24 @@ fn to_do_marks_a_new_pages_body() {...@@ -496,22 +525,24 @@ fn to_do_marks_a_new_pages_body() {
496 assert_eq!(tags, 1);525 assert_eq!(tags, 1);
497}526}
498527
499/// Against the Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share `agent`, guest): a528/// Against a disposable share `agent` (`ONESTORE_SMB_LAB=HOST:PORT`, as
500/// notebook folder copied to the share lists, a section opens and publishes typing, and the529/// `SNOWBOUND_TEST_SMB_USER` with `SNOWBOUND_TEST_SMB_PASSWORD`, or a guest): a notebook
501/// listing kept in the cache opens the section again while the server cannot be reached.530/// folder copied to the share lists, a section opens and publishes typing, and the listing
531/// kept in the cache opens the section again while the server cannot be reached.
502#[test]532#[test]
503#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]533#[ignore = "requires ONESTORE_SMB_LAB pointing to a disposable share"]
504fn a_notebook_on_a_share_saves_and_opens_offline() {534fn a_notebook_on_a_share_saves_and_opens_offline() {
505 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();535 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
536 let variable = |name| std::env::var(name).unwrap_or_default();
506 let server = |address: &str| library::Server {537 let server = |address: &str| library::Server {
507 address: address.into(),538 address: address.into(),
508 share: "agent".into(),539 share: "agent".into(),
509 user: String::new(),540 user: variable("SNOWBOUND_TEST_SMB_USER"),
510 password: String::new(),541 password: variable("SNOWBOUND_TEST_SMB_PASSWORD"),
511 domain: String::new(),542 domain: String::new(),
512 };543 };
513 let client = server(&address).connect().unwrap();544 let client = server(&address).connect().unwrap();
514 let root = format!("mobile-{}", std::process::id());545 let root = format!("snowbound-test-mobile-{}", std::process::id());
515 client.create_directory(&root).unwrap();546 client.create_directory(&root).unwrap();
516 let source = corpus("media-edit/candidate/Features.one");547 let source = corpus("media-edit/candidate/Features.one");
517 client548 client
...@@ -552,6 +583,12 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -552,6 +583,12 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
552 ))583 ))
553 };584 };
554 std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap();585 std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap();
586 notebook::location::moved(
587 cache.path(),
588 &notebook::location::smb(&address, "agent", &root),
589 &notebook::location::smb(&format!("{host}:9"), "agent", &root),
590 )
591 .unwrap();
555 let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap());592 let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap());
556 assert_eq!(offline.tabs().unwrap()[0].path, "Features.one");593 assert_eq!(offline.tabs().unwrap()[0].path, "Features.one");
557 let reopened = open(&offline, "Features.one");594 let reopened = open(&offline, "Features.one");
...@@ -561,6 +598,23 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -561,6 +598,23 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
561 .unwrap()598 .unwrap()
562 .0;599 .0;
563 assert!(canvas::search::page_text(&page).contains("Shared "));600 assert!(canvas::search::page_text(&page).contains("Shared "));
601 client.delete(&format!("{root}/Features.one")).unwrap();
602 client.delete(&root).unwrap();
603}
604
605#[test]
606fn a_share_notebook_syncs_only_its_readable_sections() {
607 let (directory, root) = copy("native-encrypted/cold-encrypted-02/notebook");
608 std::fs::copy(
609 corpus("m6/native-features-01/notebook/Empty.one"),
610 root.join("Empty.one"),
611 )
612 .unwrap();
613 let notebook =
614 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
615 let mut files = std::collections::BTreeMap::new();
616 library::files(notebook.catalog(), &mut files);
617 assert_eq!(files.keys().collect::<Vec<_>>(), ["Empty.one"]);
564}618}
565619
566/// Paths the test coordinator was asked for, with whether to write.620/// Paths the test coordinator was asked for, with whether to write.
...@@ -965,3 +1019,72 @@ fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_no...@@ -965,3 +1019,72 @@ fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_no
965 assert!(!canvas.page.editor.can_redo());1019 assert!(!canvas.page.editor.can_redo());
966 assert_eq!(stored_ink(&section, space).len(), before + 2);1020 assert_eq!(stored_ink(&section, space).len(), before + 2);
967}1021}
1022
1023/// Queues an offline edit to Features.one and closes its session, leaving the edit to the
1024/// background; the replicas in the cache, and the text edited in.
1025fn unpublished_edit() -> (tempfile::TempDir, PathBuf, Arc<Library>, ExGuid) {
1026 let (directory, root, library, section) = notebook_open("Features.one");
1027 sb_library_set_offline(&library, true);
1028 let mut canvas = canvas(&section, "Paragraph controls");
1029 focus(&mut canvas, "Collapsed parent");
1030 canvas.insert("Unpublished ".into()).unwrap();
1031 section
1032 .shared
1033 .apply(canvas.edit().unwrap().unwrap())
1034 .unwrap();
1035 assert!(eventually(|| !section
1036 .shared
1037 .section
1038 .pending()
1039 .unwrap()
1040 .is_empty()));
1041 let space = canvas.space;
1042 drop(canvas);
1043 drop(section);
1044 (directory, root, library, space)
1045}
1046
1047fn replicas(directory: &tempfile::TempDir) -> usize {
1048 let Ok(folders) = std::fs::read_dir(directory.path().join("cache/replicas")) else {
1049 return 0;
1050 };
1051 folders
1052 .filter_map(|folder| std::fs::read_dir(folder.ok()?.path()).ok())
1053 .flatten()
1054 .filter(|entry| {
1055 entry
1056 .as_ref()
1057 .is_ok_and(|entry| entry.path().extension() == Some("sqlite".as_ref()))
1058 })
1059 .count()
1060}
1061
1062#[test]
1063fn closing_publishes_a_pending_edit_so_the_notebook_renames_without_orphans() {
1064 let (directory, root, library, space) = unpublished_edit();
1065 library.close(Duration::from_secs(20)).unwrap();
1066 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1067 drop(library);
1068 assert!(eventually(|| replicas(&directory) == 0));
1069 let renamed = directory.path().join("Renamed");
1070 std::fs::rename(&root, &renamed).unwrap();
1071 let library = Arc::new(Library::open(&renamed, &directory.path().join("cache"), true).unwrap());
1072 let section = open(&library, "Features.one");
1073 let canvas = canvas(&section, "Paragraph controls");
1074 assert!(stored_text(&renamed.join("Features.one"), canvas.space).contains("Unpublished "));
1075}
1076
1077#[test]
1078fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() {
1079 let (directory, root, library, space) = unpublished_edit();
1080 let away = directory.path().join("Away");
1081 std::fs::rename(&root, &away).unwrap();
1082 assert!(library.close(Duration::from_secs(2)).is_err());
1083 assert!(replicas(&directory) > 0);
1084 std::fs::rename(&away, &root).unwrap();
1085 library.close(Duration::from_secs(20)).unwrap();
1086 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1087 drop(library);
1088 std::fs::remove_dir_all(&root).unwrap();
1089 assert!(eventually(|| replicas(&directory) == 0));
1090}
crates/notebook/Cargo.toml+3
...@@ -20,6 +20,9 @@ smb2 = { version = "=0.21.0", optional = true }...@@ -20,6 +20,9 @@ smb2 = { version = "=0.21.0", optional = true }
20tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true }20tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true }
21zeroize = { version = "1.9.0", optional = true }21zeroize = { version = "1.9.0", optional = true }
2222
23[target.'cfg(target_vendor = "apple")'.dependencies]
24nix = { version = "0.31", default-features = false, features = ["fs"] }
25
23[dev-dependencies]26[dev-dependencies]
24libc = "0.2"27libc = "0.2"
25serde_json = "1"28serde_json = "1"
crates/notebook/README.md+49-3
...@@ -62,6 +62,8 @@ failures; `notify` runs on a background thread whenever an event waits....@@ -62,6 +62,8 @@ failures; `notify` runs on a background thread whenever an event waits.
62removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an62removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an
63edit names is the host's: the app passes the account's full name.63edit names is the host's: the app passes the account's full name.
64`import_page` creates a page holding a copy of another; `delete_pages` removes pages.64`import_page` creates a page holding a copy of another; `delete_pages` removes pages.
65`set_pause(pause)` has edits publish once `pause` passes without another (at most 30 s after the
66first waiting), as a notebook on a cloud drive does; `wake()` publishes them at once.
65`sync_status()` gives when the section file was last reached, why it could not be since67`sync_status()` gives when the section file was last reached, why it could not be since
66and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the68and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the
67worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.69worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.
...@@ -115,6 +117,17 @@ the remote left in place, or last, and a page the remote removed comes back as a...@@ -115,6 +117,17 @@ the remote left in place, or last, and a page the remote removed comes back as a
115of the local one, placed the same way (`corpus/conflict-page/native-pages`,117of the local one, placed the same way (`corpus/conflict-page/native-pages`,
116`native-restore`). Nothing blocks the queue.118`native-restore`). Nothing blocks the queue.
117119
120A remote a file provider keeps may list versions of the file beside it (`Remote::versions`,
121`version`, `retire`), as iCloud Drive keeps a commit that lost to another device's. Each step
122merges them first (`resolve.rs`): per object space, the newest revision of the version's that
123the file holds, or holds merged, is where the two last agreed; the section opened at those
124revisions (`onestore::Section::open_at`) is the base the version's changes lower from
125(`lower_page`), and they replay on the file through the rebase rules above, conflict pages where
126they clash. The revisions the merge writes are named after the version's it merged
127(`Section::seal_as`), so a version merged once, here or on another device, merges as nothing
128again. It is published on the file's stamp, then retired; one of another section, or one that
129cannot be read, is retired with `keep` for the remote to keep beside the file.
130
118Publication attempts are recorded before network I/O. An attempt confirms only131Publication attempts are recorded before network I/O. An attempt confirms only
119when the remote holds its revisions, or every page it changed as it changed them,132when the remote holds its revisions, or every page it changed as it changed them,
120followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never133followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never
...@@ -273,9 +286,12 @@ cache as it was....@@ -273,9 +286,12 @@ cache as it was.
273286
274Each file read must be a consistent, bounded snapshot. The result is an287Each file read must be a consistent, bounded snapshot. The result is an
275observation across multiple files, not an atomic notebook transaction or288observation across multiple files, not an atomic notebook transaction or
276authorization to publish an edit. Refresh rejects observed topology changes and289authorization to publish an edit. Refresh rejects observed topology changes. Of
277duplicate physical files with the same logical identity. Retain the last accepted290several files with one identity (a `Name 2.one` copy), the one its folder's TOC names,
278catalog if discovery fails; a connection failure does not mean files were deleted.291else the newest, lists; the others list in `unavailable` as `Reason::Copy`. iOS's
292`.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`.
293Retain the last accepted catalog if discovery fails; a connection failure does not mean
294files were deleted.
279295
280`read_external_asset` resolves a validated UUID `.onebin` filename beneath the296`read_external_asset` resolves a validated UUID `.onebin` filename beneath the
281selected section's sibling `_onefiles` folder and returns exact bounded bytes.297selected section's sibling `_onefiles` folder and returns exact bounded bytes.
...@@ -342,6 +358,30 @@ is `Reordered`. A failed read returns the error and keeps...@@ -342,6 +358,30 @@ is `Reordered`. A failed read returns the error and keeps
342the previous catalog, so an unreachable share never reads as an emptied358the previous catalog, so an unreachable share never reads as an emptied
343notebook.359notebook.
344360
361## Snowbound's folder
362
363`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which
364OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it
365through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a
366dot name hidden without storing it) or macOS's `UF_HIDDEN`, which an smbfs mount passes
367to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
368without the folder is whole.
369
370```text
371.snowbound/
372├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art
373└ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten
374```
375
376`Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it,
377keeps the picture, then rereads `tags.json`, merges its mapping and replaces the file,
378reading it back and merging again until its own holds. `sidecar::merge` keeps every tag
379either side mapped; one tag mapped by both keeps the later `mapped`, then the greater art
380name. A mapping two writers replace within one round trip of each other can still lose
381one side's new tag until that side maps it again. `tag_art` reads the mappings, passing
382over entries that name no picture, and `tag_art_file` returns a picture once its bytes
383match its name.
384
345## SMB (feature `smb`)385## SMB (feature `smb`)
346386
347`notebook::smb` provides blocking SMB access for OneNote sections and387`notebook::smb` provides blocking SMB access for OneNote sections and
...@@ -399,6 +439,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not...@@ -399,6 +439,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not
399directory names or sizes. Missing paths, denied access and non-directory paths439directory names or sizes. Missing paths, denied access and non-directory paths
400have distinct I/O error kinds.440have distinct I/O error kinds.
401441
442`smb::shares(address, credentials, timeout)` lists the disk shares a server offers the
443account, over `IPC$` and srvsvc. `smb::Refusal::of(&error)` names why `Client::connect`,
444`shares` or `read_dir` failed as a person remedies it: unreachable, SMB1 only, sign-in
445refused, no such share, denied, no such folder. A server that turns SMB2's negotiation
446away is asked for SMB1's `NT LM 0.12` once, which an SMB1-only server agrees to.
447
402`Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as448`Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as
403OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without449OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without
404probing the connection, so an idle watch sends nothing. `changed` hears each batch of changed450probing the connection, so an idle watch sends nothing. `changed` hears each batch of changed
crates/notebook/src/background.rs+20-1
...@@ -849,8 +849,15 @@ fn step<R: Remote>(...@@ -849,8 +849,15 @@ fn step<R: Remote>(
849 Err(error) => (None, Err(error)),849 Err(error) => (None, Err(error)),
850 };850 };
851 }851 }
852 // A version kept beside the file leaves its stamp as it was.
853 let versions = match remote.versions() {
854 Ok(versions) => versions,
855 Err(error) => return (None, Err(Error::RemoteIo(error))),
856 };
852 match crate::peek(replica) {857 match crate::peek(replica) {
853 Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))),858 Ok((base, 0)) if base == stamp && versions.is_empty() => {
859 return (Some(0), Ok((stamp, moved)));
860 }
854 Err(error) if error.busy() => return (None, Ok((stamp, false))),861 Err(error) if error.busy() => return (None, Ok((stamp, false))),
855 _ => {}862 _ => {}
856 }863 }
...@@ -907,6 +914,18 @@ impl<R: Remote> Remote for Discovered<'_, R> {...@@ -907,6 +914,18 @@ impl<R: Remote> Remote for Discovered<'_, R> {
907 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), onestore::CommitError> {914 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), onestore::CommitError> {
908 self.remote.confirm(base)915 self.remote.confirm(base)
909 }916 }
917
918 fn versions(&mut self) -> io::Result<Vec<crate::Version>> {
919 self.remote.versions()
920 }
921
922 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
923 self.remote.version(id)
924 }
925
926 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
927 self.remote.retire(id, keep)
928 }
910}929}
911930
912#[cfg(test)]931#[cfg(test)]
crates/notebook/src/discover.rs+260-58
...@@ -8,12 +8,14 @@ use serde::{Deserialize, Serialize};...@@ -8,12 +8,14 @@ use serde::{Deserialize, Serialize};
8use std::{8use std::{
9 collections::{BTreeMap, BTreeSet},9 collections::{BTreeMap, BTreeSet},
10 io,10 io,
11 time::Duration,
11};12};
1213
13mod source;14mod source;
14pub use source::Local;15pub use source::Local;
15#[cfg(feature = "smb")]16#[cfg(feature = "smb")]
16pub use source::Smb;17pub use source::Smb;
18pub(crate) use source::placeholder;
1719
18#[derive(Debug, Serialize)]20#[derive(Debug, Serialize)]
19pub struct Section {21pub struct Section {
...@@ -21,6 +23,10 @@ pub struct Section {...@@ -21,6 +23,10 @@ pub struct Section {
21 /// Header.guidFile, also used by FileIdentityGuid in a parent TOC.23 /// Header.guidFile, also used by FileIdentityGuid in a parent TOC.
22 pub file_id: [u8; 16],24 pub file_id: [u8; 16],
23 pub state: SectionState,25 pub state: SectionState,
26 /// Another section of the catalog holds the same file, as a copy made beside it does, and
27 /// is the one its folder's TOC lists by that identity (or else has the shorter path). OneNote
28 /// opens both; the copy's TOC entry, where one exists, is its own.
29 pub copy: bool,
24}30}
2531
26#[derive(Debug, Serialize)]32#[derive(Debug, Serialize)]
...@@ -54,6 +60,31 @@ pub struct Unavailable {...@@ -54,6 +60,31 @@ pub struct Unavailable {
54 pub path: String,60 pub path: String,
55 pub group: bool,61 pub group: bool,
56 pub error: String,62 pub error: String,
63 pub reason: Reason,
64}
65
66#[derive(Debug, PartialEq, Eq, Serialize)]
67pub enum Reason {
68 /// Access denied, or gone mid-listing.
69 Denied,
70 /// Not yet on this device (`EntryKind::Evicted`), for the host to download.
71 Evicted,
72 /// Mid-write through every retry.
73 InUse,
74 /// Not a notebook file this can read: corrupt, or too large.
75 Unreadable,
76 /// Another group at `of` holds the same TOC; the catalog lists that one.
77 Copy { of: String },
78}
79
80/// A section file or group folder holding an identity, one of the copies discovery chooses
81/// among.
82struct Claim {
83 path: String,
84 group: bool,
85 /// Its folder's TOC lists the identity under its name.
86 listed: bool,
87 modified: u64,
57}88}
5889
59#[derive(Debug, Serialize)]90#[derive(Debug, Serialize)]
...@@ -77,6 +108,9 @@ pub enum EntryKind {...@@ -77,6 +108,9 @@ pub enum EntryKind {
77 File,108 File,
78 Directory,109 Directory,
79 Other,110 Other,
111 /// A notebook file kept elsewhere and not yet on this device, as iOS lists one that
112 /// iCloud Drive evicted.
113 Evicted,
80}114}
81115
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
...@@ -107,6 +141,9 @@ pub struct Cache {...@@ -107,6 +141,9 @@ pub struct Cache {
107141
108/// The most bytes of images a cache holds for `Cache::take`.142/// The most bytes of images a cache holds for `Cache::take`.
109const HELD: usize = 64 << 20;143const HELD: usize = 64 << 20;
144/// How many more times a read that meets a commit in progress is tried, and how far apart.
145const RETRIES: usize = 3;
146const RETRY: Duration = Duration::from_millis(250);
110147
111/// A notebook file as discovery read it.148/// A notebook file as discovery read it.
112#[derive(Clone, Serialize, Deserialize)]149#[derive(Clone, Serialize, Deserialize)]
...@@ -135,17 +172,18 @@ impl Cache {...@@ -135,17 +172,18 @@ impl Cache {
135 /// A failed discovery leaves the cache as it was.172 /// A failed discovery leaves the cache as it was.
136 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {173 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
137 let mut remaining = limits.entries;174 let mut remaining = limits.entries;
138 let mut identities = BTreeMap::new();175 let mut claims = BTreeMap::new();
139 let mut found = Cache::default();176 let mut found = Cache::default();
140 let folder = scan(177 let mut folder = scan(
141 source,178 source,
142 "",179 "",
143 &limits,180 &limits,
144 0,181 0,
145 &mut remaining,182 &mut remaining,
146 &mut identities,183 &mut claims,
147 (&self.read, &mut found),184 (&self.read, &mut found),
148 )?;185 )?;
186 set_aside(&mut folder, &claims);
149 *self = found;187 *self = found;
150 Ok(folder)188 Ok(folder)
151 }189 }
...@@ -259,12 +297,6 @@ pub enum Error {...@@ -259,12 +297,6 @@ pub enum Error {
259 Changed { path: String },297 Changed { path: String },
260 #[error("Invalid or unsupported directory entry: {path}")]298 #[error("Invalid or unsupported directory entry: {path}")]
261 Entry { path: String },299 Entry { path: String },
262 #[error("File identity occurs at both {first} and {second}")]
263 DuplicateIdentity {
264 file: [u8; 16],
265 first: String,
266 second: String,
267 },
268}300}
269301
270/// Discovers rooted notebook topology within caller-specified work and size limits.302/// Discovers rooted notebook topology within caller-specified work and size limits.
...@@ -279,7 +311,7 @@ fn scan(...@@ -279,7 +311,7 @@ fn scan(
279 limits: &Limits,311 limits: &Limits,
280 depth: usize,312 depth: usize,
281 remaining: &mut usize,313 remaining: &mut usize,
282 identities: &mut BTreeMap<[u8; 16], String>,314 claims: &mut BTreeMap<[u8; 16], Vec<Claim>>,
283 (cached, found): (&BTreeMap<String, Read>, &mut Cache),315 (cached, found): (&BTreeMap<String, Read>, &mut Cache),
284) -> Result<Folder, Error> {316) -> Result<Folder, Error> {
285 if depth > limits.depth {317 if depth > limits.depth {
...@@ -324,18 +356,19 @@ fn scan(...@@ -324,18 +356,19 @@ fn scan(
324 limits,356 limits,
325 depth + 1,357 depth + 1,
326 remaining,358 remaining,
327 identities,359 claims,
328 (cached, found),360 (cached, found),
329 ) {361 ) {
330 Ok(group) => result.groups.push(group),362 Ok(group) => result.groups.push(group),
331 Err(error @ Error::Io { .. }) if unavailable(&error) => {363 Err(error) => match unavailable(&error) {
332 result.unavailable.push(Unavailable {364 Some(reason) => result.unavailable.push(Unavailable {
333 path: child,365 path: child,
334 group: true,366 group: true,
335 error: error.to_string(),367 error: error.to_string(),
336 });368 reason,
337 }369 }),
338 Err(error) => return Err(error),370 None => return Err(error),
371 },
339 }372 }
340 continue;373 continue;
341 }374 }
...@@ -347,6 +380,19 @@ fn scan(...@@ -347,6 +380,19 @@ fn scan(
347 } else {380 } else {
348 continue;381 continue;
349 };382 };
383 // Without an evicted TOC the folder lists in path order; nothing writes a second one,
384 // as its placeholder blocks creating it.
385 if entry.kind == EntryKind::Evicted {
386 if expected == FileType::Section {
387 result.unavailable.push(Unavailable {
388 path: child,
389 group: false,
390 error: "Not downloaded to this device yet".into(),
391 reason: Reason::Evicted,
392 });
393 }
394 continue;
395 }
350 if entry.kind != EntryKind::File396 if entry.kind != EntryKind::File
351 || (expected == FileType::TableOfContents && result.toc.is_some())397 || (expected == FileType::TableOfContents && result.toc.is_some())
352 {398 {
...@@ -367,41 +413,71 @@ fn scan(...@@ -367,41 +413,71 @@ fn scan(
367 .and_then(Read::file_id)413 .and_then(Read::file_id)
368 .and_then(|known| source.copy(&child, known));414 .and_then(|known| source.copy(&child, known));
369 let fetched = copied.is_none();415 let fetched = copied.is_none();
370 let bytes =416 // A section that cannot be read lists as unavailable; the rest still open.
371 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {417 let mut list_unavailable = |error: Error| match unavailable(&error) {
372 Ok(bytes) => bytes,418 Some(reason) if expected == FileType::Section => {
373 Err(error) => {419 result.unavailable.push(Unavailable {
374 let error = Error::Io {420 path: child.clone(),
375 path: child.clone(),421 group: false,
376 error,422 error: error.to_string(),
377 };423 reason,
378 if expected == FileType::Section && unavailable(&error) {424 });
379 result.unavailable.push(Unavailable {425 Ok(())
380 path: child,426 }
381 group: false,427 _ => Err(error),
382 error: error.to_string(),428 };
383 });429 let read = copied.map_or_else(
384 continue;430 || {
431 let mut read = source.read(&child, limits.bytes_per_file);
432 // A read that meets a commit in progress succeeds once it lands.
433 for _ in 0..RETRIES {
434 if !read.as_ref().is_err_and(|error| {
435 matches!(
436 error.kind(),
437 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
438 )
439 }) {
440 break;
385 }441 }
386 return Err(error);442 std::thread::sleep(RETRY);
443 read = source.read(&child, limits.bytes_per_file);
387 }444 }
388 };445 read
446 },
447 Ok,
448 );
449 let bytes = match read {
450 Ok(bytes) => bytes,
451 Err(error) => {
452 list_unavailable(Error::Io {
453 path: child.clone(),
454 error,
455 })?;
456 continue;
457 }
458 };
389 if bytes.len() > limits.bytes_per_file {459 if bytes.len() > limits.bytes_per_file {
390 return Err(Error::Limit { path: child });460 return Err(Error::Limit { path: child });
391 }461 }
392 let store = Store::parse(&bytes).map_err(|error| Error::Document {462 let parsed = Store::parse(&bytes).and_then(|store| {
393 path: child.clone(),463 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
394 error,464 return Err(onestore::Error {
395 })?;
396 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
397 return Err(Error::Document {
398 path: child,
399 error: onestore::Error {
400 offset: 0,465 offset: 0,
401 message: "Unexpected file type or checksum mismatch",466 message: "Unexpected file type or checksum mismatch",
402 },467 });
403 });468 }
404 }469 Ok(store)
470 });
471 let store = match parsed {
472 Ok(store) => store,
473 Err(error) => {
474 list_unavailable(Error::Document {
475 path: child.clone(),
476 error,
477 })?;
478 continue;
479 }
480 };
405 let held = held(&store, expected);481 let held = held(&store, expected);
406 let file_id = store.header.file_id;482 let file_id = store.header.file_id;
407 if let Ok(held) = &held {483 if let Ok(held) = &held {
...@@ -438,11 +514,13 @@ fn scan(...@@ -438,11 +514,13 @@ fn scan(
438 color,514 color,
439 document,515 document,
440 },516 },
517 copy: false,
441 }),518 }),
442 Ok(Held::Locked) => result.sections.push(Section {519 Ok(Held::Locked) => result.sections.push(Section {
443 path: child.clone(),520 path: child.clone(),
444 file_id,521 file_id,
445 state: SectionState::Locked,522 state: SectionState::Locked,
523 copy: false,
446 }),524 }),
447 Ok(Held::Toc(unresolved)) => {525 Ok(Held::Toc(unresolved)) => {
448 result.toc = Some(Toc {526 result.toc = Some(Toc {
...@@ -455,16 +533,10 @@ fn scan(...@@ -455,16 +533,10 @@ fn scan(
455 path: child.clone(),533 path: child.clone(),
456 file_id,534 file_id,
457 state: SectionState::Unreadable(error),535 state: SectionState::Unreadable(error),
536 copy: false,
458 }),537 }),
459 Err(error) => return Err(Error::Document { path: child, error }),538 Err(error) => return Err(Error::Document { path: child, error }),
460 }539 }
461 if let Some(first) = identities.insert(file_id, child.clone()) {
462 return Err(Error::DuplicateIdentity {
463 file: file_id,
464 first,
465 second: child,
466 });
467 }
468 }540 }
469 let order: BTreeMap<_, _> = result541 let order: BTreeMap<_, _> = result
470 .toc542 .toc
...@@ -492,6 +564,39 @@ fn scan(...@@ -492,6 +564,39 @@ fn scan(
492 &b.path,564 &b.path,
493 ))565 ))
494 });566 });
567 let listed = |file: [u8; 16], path: &str| {
568 let name = path.rsplit('/').next().unwrap_or(path);
569 result
570 .toc
571 .iter()
572 .flat_map(|toc| &toc.unresolved)
573 .any(|entry| {
574 entry.file == file
575 && entry
576 .filename
577 .as_deref()
578 .is_some_and(|filename| filename.eq_ignore_ascii_case(name))
579 })
580 };
581 let modified = |path: &str| found.read.get(path).map_or(0, |read| read.listed.modified);
582 for section in &result.sections {
583 claims.entry(section.file_id).or_default().push(Claim {
584 path: section.path.clone(),
585 group: false,
586 listed: listed(section.file_id, &section.path),
587 modified: modified(&section.path),
588 });
589 }
590 for group in &result.groups {
591 if let Some(toc) = &group.toc {
592 claims.entry(toc.file_id).or_default().push(Claim {
593 path: group.path.clone(),
594 group: true,
595 listed: listed(toc.file_id, &group.path),
596 modified: modified(&join(&group.path, &toc.filename)),
597 });
598 }
599 }
495 let present: BTreeSet<_> = result600 let present: BTreeSet<_> = result
496 .sections601 .sections
497 .iter()602 .iter()
...@@ -528,6 +633,96 @@ fn scan(...@@ -528,6 +633,96 @@ fn scan(
528 Ok(result)633 Ok(result)
529}634}
530635
636/// Keeps one group of each TOC identity in the catalog, the one its parent's TOC lists or else
637/// the newest; the others list as unavailable copies with their sections. Of sections holding
638/// one file, each lists, and all but the one its folder's TOC lists (or else the one with the
639/// shortest path) are marked as copies: a choice that stands while the files keep their names.
640fn set_aside(folder: &mut Folder, claims: &BTreeMap<[u8; 16], Vec<Claim>>) {
641 let mut copies = BTreeMap::new();
642 for claims in claims.values() {
643 let mut groups: Vec<_> = claims.iter().filter(|claim| claim.group).collect();
644 groups.sort_by_key(|claim| {
645 (
646 !claim.listed,
647 std::cmp::Reverse(claim.modified),
648 claim.path.len(),
649 &claim.path,
650 )
651 });
652 if let [original, rest @ ..] = &groups[..] {
653 for copy in rest {
654 copies.insert(copy.path.clone(), original.path.clone());
655 }
656 }
657 }
658 if !copies.is_empty() {
659 demote(folder, &copies);
660 }
661 let mut sections = BTreeSet::new();
662 for claims in claims.values() {
663 let mut held: Vec<_> = claims
664 .iter()
665 .filter(|claim| {
666 !claim.group
667 && !copies
668 .keys()
669 .any(|copy: &String| claim.path.starts_with(&format!("{copy}/")))
670 })
671 .collect();
672 held.sort_by_key(|claim| (!claim.listed, claim.path.len(), &claim.path));
673 sections.extend(held.iter().skip(1).map(|claim| claim.path.clone()));
674 }
675 if !sections.is_empty() {
676 mark(folder, &sections);
677 }
678}
679
680fn demote(folder: &mut Folder, copies: &BTreeMap<String, String>) {
681 folder.groups.retain(|group| {
682 let Some(of) = copies.get(&group.path) else {
683 return true;
684 };
685 let name = of.rsplit('/').next().unwrap_or(of);
686 folder.unavailable.push(Unavailable {
687 path: group.path.clone(),
688 group: true,
689 error: format!("A copy of \u{201c}{name}\u{201d}, which opens instead"),
690 reason: Reason::Copy { of: of.clone() },
691 });
692 false
693 });
694 for group in &mut folder.groups {
695 demote(group, copies);
696 }
697}
698
699fn mark(folder: &mut Folder, copies: &BTreeSet<String>) {
700 for section in &mut folder.sections {
701 section.copy = copies.contains(&section.path);
702 }
703 for group in &mut folder.groups {
704 mark(group, copies);
705 }
706}
707
708/// Whether `store` holds a password-protected section.
709pub(crate) fn locked(store: &Store) -> bool {
710 RevisionIndex::parse(store)
711 .and_then(|index| {
712 let document = Document::parse(&index)?;
713 Ok(encrypted(document.active(document.root)?))
714 })
715 .unwrap_or(false)
716}
717
718fn encrypted(revision: &onestore::document::Revision<'_>) -> bool {
719 revision
720 .roots
721 .get(&1)
722 .and_then(|id| revision.nodes.get(id))
723 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
724}
725
531/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.726/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.
532fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {727fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
533 let index = RevisionIndex::parse(store)?;728 let index = RevisionIndex::parse(store)?;
...@@ -540,7 +735,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {...@@ -540,7 +735,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
540 .and_then(|id| revision.nodes.get(id))735 .and_then(|id| revision.nodes.get(id))
541 };736 };
542 if expected == FileType::Section {737 if expected == FileType::Section {
543 if root(1).is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) {738 if encrypted(revision) {
544 return Ok(Held::Locked);739 return Ok(Held::Locked);
545 }740 }
546 index.validate_current()?;741 index.validate_current()?;
...@@ -592,12 +787,19 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {...@@ -592,12 +787,19 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
592 Ok(Held::Toc(unresolved))787 Ok(Held::Toc(unresolved))
593}788}
594789
595/// Access denied or a file gone mid-listing; a lost connection stays fatal.790/// Why a file or group that failed lists as unavailable; `None` fails the whole discovery,
596fn unavailable(error: &Error) -> bool {791/// as a lost connection does.
597 matches!(error, Error::Io { error, .. } if matches!(792fn unavailable(error: &Error) -> Option<Reason> {
598 error.kind(),793 match error {
599 io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound794 Error::Io { error, .. } => match error.kind() {
600 ))795 io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound => Some(Reason::Denied),
796 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy => Some(Reason::InUse),
797 io::ErrorKind::InvalidData | io::ErrorKind::FileTooLarge => Some(Reason::Unreadable),
798 _ => None,
799 },
800 Error::Document { .. } => Some(Reason::Unreadable),
801 _ => None,
802 }
601}803}
602804
603/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._`805/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._`
crates/notebook/src/discover/source.rs+24
...@@ -1,9 +1,23 @@...@@ -1,9 +1,23 @@
1use super::{Entry, EntryKind, Listed, Source, component};1use super::{Entry, EntryKind, Listed, Source, component};
2use std::{2use std::{
3 collections::BTreeSet,
3 io,4 io,
4 path::{Path, PathBuf},5 path::{Path, PathBuf},
5};6};
67
8/// The notebook file iOS lists as `.Name.icloud` while iCloud Drive keeps it elsewhere.
9fn evicted(name: &str) -> Option<&str> {
10 let real = name.strip_prefix('.')?.strip_suffix(".icloud")?;
11 let lower = real.to_ascii_lowercase();
12 (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real)
13}
14
15/// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere.
16pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> {
17 let name = path.file_name()?.to_str()?;
18 Some(path.with_file_name(format!(".{name}.icloud")))
19}
20
7/// A canonical local root; symbolic-link entries are not traversed.21/// A canonical local root; symbolic-link entries are not traversed.
8pub struct Local {22pub struct Local {
9 root: PathBuf,23 root: PathBuf,
...@@ -62,6 +76,16 @@ impl Source for Local {...@@ -62,6 +76,16 @@ impl Source for Local {
62 },76 },
63 });77 });
64 }78 }
79 let present: BTreeSet<_> = entries.iter().map(|entry| entry.name.clone()).collect();
80 entries.retain_mut(|entry| match evicted(&entry.name).map(str::to_owned) {
81 Some(real) if present.contains(&real) => false,
82 Some(real) => {
83 entry.name = real;
84 entry.kind = EntryKind::Evicted;
85 true
86 }
87 None => true,
88 });
65 Ok(entries)89 Ok(entries)
66 }90 }
6791
crates/notebook/src/lib.rs+13
...@@ -22,13 +22,17 @@ use std::{...@@ -22,13 +22,17 @@ use std::{
22mod assets;22mod assets;
23mod background;23mod background;
24mod base;24mod base;
25pub mod location;
25mod merge;26mod merge;
26mod migrate;27mod migrate;
27mod queue;28mod queue;
28mod recovery;29mod recovery;
30mod resolve;
31pub use resolve::Version;
29mod schema;32mod schema;
30pub mod session;33pub mod session;
31pub use recovery::{Recovery, RecoverySummary};34pub use recovery::{Recovery, RecoverySummary};
35pub mod sidecar;
32mod sync;36mod sync;
33pub use sync::{EditStatus, Remote, Synced};37pub use sync::{EditStatus, Remote, Synced};
34mod worker;38mod worker;
...@@ -341,6 +345,15 @@ fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> {...@@ -341,6 +345,15 @@ fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> {
341 .map_err(|_| io::Error::other("Cache owner panicked").into())345 .map_err(|_| io::Error::other("Cache owner panicked").into())
342}346}
343347
348/// Tells the worker a burst of local edits is durable.
349fn edited(worker: &Mutex<std::sync::Weak<worker::Signal>>) {
350 if let Ok(worker) = worker.lock()
351 && let Some(worker) = worker.upgrade()
352 {
353 worker.edited();
354 }
355}
356
344fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) {357fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) {
345 if let Ok(worker) = worker.lock()358 if let Ok(worker) = worker.lock()
346 && let Some(worker) = worker.upgrade()359 && let Some(worker) = worker.upgrade()
crates/notebook/src/location.rs created+184
...@@ -0,0 +1,184 @@
1//! Where section replicas live in the app's cache: a folder per notebook location, then a
2//! file per section identity. OneNote lets a notebook folder be copied with its sections
3//! keeping their identities, so identity alone cannot tell a copy's replica from the
4//! original's; the location keeps each copy's queue its own.
5//!
6//! A location is a string naming where a notebook or lone section is reached: the canonical
7//! path of a local folder or file (`local`), or `smb` for one on a share.
8
9use onestore::{Header, Stamp};
10use std::{
11 io,
12 path::{Path, PathBuf},
13};
14
15/// The file in a replica folder naming the location its replicas belong to.
16const LOCATION: &str = "location";
17
18/// The folder in `cache` holding the replicas of the sections at `location`.
19pub fn folder(cache: &Path, location: &str) -> PathBuf {
20 cache
21 .join("replicas")
22 .join(hex(&<sha2::Sha256 as sha2::Digest>::digest(location)[..16]))
23}
24
25/// The location of the local folder or file at `path`, which may have just been moved away:
26/// its canonical path, or its folder's with its name.
27pub fn local(path: &Path) -> io::Result<String> {
28 let canonical =
29 path.canonicalize()
30 .or_else(|error| match (path.parent(), path.file_name()) {
31 (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)),
32 _ => Err(error),
33 })?;
34 Ok(canonical.to_string_lossy().into_owned())
35}
36
37/// The location of the notebook folder `root` in `share` on the server at `address`, as its
38/// host names them.
39pub fn smb(address: &str, share: &str, root: &str) -> String {
40 let root = root.replace('\\', "/");
41 format!("smb://{address}/{share}/{}", root.trim_matches('/'))
42}
43
44/// Moves the replicas of the sections at `from` to `to`, as when the app renames or moves a
45/// notebook; each replica `to` already holds for the same section stays. Every replica moved
46/// must be closed.
47pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
48 let old = folder(cache, from);
49 if !old.exists() {
50 return Ok(());
51 }
52 let new = claimed(cache, to)?;
53 for entry in std::fs::read_dir(&old)? {
54 let name = entry?.file_name();
55 if Path::new(&name).extension() == Some("sqlite".as_ref()) && !new.join(&name).exists() {
56 adopt(&old.join(&name), &new.join(&name))?;
57 }
58 }
59 tidy(&old);
60 Ok(())
61}
62
63/// The folder for `location`, made and labelled with it where it was not.
64fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
65 let folder = folder(cache, location);
66 let label = folder.join(LOCATION);
67 if !label.exists() {
68 std::fs::create_dir_all(&folder)?;
69 std::fs::write(&label, location)?;
70 }
71 Ok(folder)
72}
73
74/// The folder for `location`, having moved into it a replica for each of `sections` it lacks
75/// from where one was left behind:
76///
77/// - the cache's top or its `smb` folder, where replicas were named by identity alone before
78/// locations named folders: taken by the first location to open the section, as that
79/// replica served whichever copy opened then;
80/// - the folder of a local location that no longer exists, as a notebook moved outside the
81/// app leaves: taken only when the section file, as `stamp` reads it, stands as the
82/// replica's base or has moved on from it (a later generation). A replica whose base is
83/// newer than the file, or a different state of the same generation, belongs to another
84/// copy of the section and stays.
85///
86/// A replica another process holds stays where it is.
87pub(crate) fn claim(
88 cache: &Path,
89 location: &str,
90 sections: &[[u8; 16]],
91 stamp: impl Fn(&[u8; 16]) -> Option<Stamp>,
92) -> io::Result<PathBuf> {
93 let folder = claimed(cache, location)?;
94 let missing: Vec<&[u8; 16]> = sections
95 .iter()
96 .filter(|identity| !crate::session::replica_file(&folder, identity).exists())
97 .collect();
98 if missing.is_empty() {
99 return Ok(folder);
100 }
101 let orphans = orphans(cache);
102 for identity in missing {
103 let legacy = [cache.to_owned(), cache.join("smb")]
104 .into_iter()
105 .map(|legacy| (crate::session::replica_file(&legacy, identity), true));
106 let left = orphans
107 .iter()
108 .map(|orphan| (crate::session::replica_file(orphan, identity), false));
109 for (replica, legacy) in legacy.chain(left) {
110 if !replica.exists() {
111 continue;
112 }
113 let adoptable = match crate::peek(&replica) {
114 Ok((base, _)) => {
115 legacy || stamp(identity).is_some_and(|file| follows(&file, &base))
116 }
117 Err(error) if error.busy() => false,
118 // An older schema, which opening converts, predates locations.
119 Err(_) => legacy,
120 };
121 if adoptable {
122 adopt(&replica, &crate::session::replica_file(&folder, identity))?;
123 if let Some(parent) = replica.parent().filter(|_| !legacy) {
124 tidy(parent);
125 }
126 break;
127 }
128 }
129 }
130 Ok(folder)
131}
132
133/// The replica folders of local locations that no longer exist.
134fn orphans(cache: &Path) -> Vec<PathBuf> {
135 let Ok(entries) = std::fs::read_dir(cache.join("replicas")) else {
136 return Vec::new();
137 };
138 entries
139 .filter_map(|entry| {
140 let folder = entry.ok()?.path();
141 let location = std::fs::read_to_string(folder.join(LOCATION)).ok()?;
142 let path = Path::new(&location);
143 (path.is_absolute() && !path.exists()).then_some(folder)
144 })
145 .collect()
146}
147
148/// Whether the section file standing as `file` may take a replica whose base is `base`.
149fn follows(file: &Stamp, base: &Stamp) -> bool {
150 file == base
151 || matches!(
152 (Header::parse(&file.header), Header::parse(&base.header)),
153 (Ok(file), Ok(base)) if file.generation > base.generation
154 )
155}
156
157/// Moves the replica at `from`, with the write-ahead log a crash may have left, to `to`.
158fn adopt(from: &Path, to: &Path) -> io::Result<()> {
159 for suffix in ["-wal", "-shm", ""] {
160 let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned());
161 source.push(suffix);
162 target.push(suffix);
163 match std::fs::rename(&source, &target) {
164 Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {}
165 moved => moved?,
166 }
167 }
168 Ok(())
169}
170
171/// Removes a replica folder that holds no replica.
172fn tidy(folder: &Path) {
173 let empty = std::fs::read_dir(folder).is_ok_and(|mut entries| {
174 entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION))
175 });
176 if empty {
177 let _ = std::fs::remove_file(folder.join(LOCATION));
178 let _ = std::fs::remove_dir(folder);
179 }
180}
181
182fn hex(bytes: &[u8]) -> String {
183 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
184}
crates/notebook/src/merge.rs+39-6
...@@ -187,8 +187,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> {...@@ -187,8 +187,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
187/// applies it to `new`, the merged section: a conflict page under the remote's page187/// applies it to `new`, the merged section: a conflict page under the remote's page
188/// marking `objects` where `page` holds them, or, where the remote removed the page, the188/// marking `objects` where `page` holds them, or, where the remote removed the page, the
189/// page put back as a copy before the next page after it in `local`, the section as the189/// page put back as a copy before the next page after it in `local`, the section as the
190/// queue leaves it, that the remote did not move (`moved`). Content outside the page model190/// queue leaves it, that the remote did not move (`moved`). The page it makes is in the space
191/// stays on the remote's page only; `at` is now.191/// `guid` names, or a fresh one. Content outside the page model stays on the remote's page
192/// only; `at` is now.
192#[allow(clippy::too_many_arguments)]193#[allow(clippy::too_many_arguments)]
193pub(crate) fn conflict_page(194pub(crate) fn conflict_page(
194 new: &mut Section<'_>,195 new: &mut Section<'_>,
...@@ -199,7 +200,12 @@ pub(crate) fn conflict_page(...@@ -199,7 +200,12 @@ pub(crate) fn conflict_page(
199 author: &str,200 author: &str,
200 objects: &BTreeSet<ExGuid>,201 objects: &BTreeSet<ExGuid>,
201 at: u64,202 at: u64,
203 guid: Option<[u8; 16]>,
202) -> Result<Edit> {204) -> Result<Edit> {
205 let placed = |creation: PageCreation| match guid {
206 Some(guid) => creation.in_space(guid),
207 None => Ok(creation),
208 };
203 let mut page = page.clone();209 let mut page = page.clone();
204 page.objects210 page.objects
205 .retain(|object| !matches!(object, PageObject::Unsupported(_)));211 .retain(|object| !matches!(object, PageObject::Unsupported(_)));
...@@ -218,7 +224,11 @@ pub(crate) fn conflict_page(...@@ -218,7 +224,11 @@ pub(crate) fn conflict_page(
218 .any(|object| matches!(object, PageObject::Title(_)));224 .any(|object| matches!(object, PageObject::Title(_)));
219 vec![Op::Section(SectionOp::Conflict {225 vec![Op::Section(SectionOp::Conflict {
220 of: space,226 of: space,
221 creation: PageCreation::new(None, titled.then_some(page.title.as_str()), author)?,227 creation: placed(PageCreation::new(
228 None,
229 titled.then_some(page.title.as_str()),
230 author,
231 )?)?,
222 page: copy,232 page: copy,
223 objects: marked,233 objects: marked,
224 })]234 })]
...@@ -231,7 +241,8 @@ pub(crate) fn conflict_page(...@@ -231,7 +241,8 @@ pub(crate) fn conflict_page(
231 .iter()241 .iter()
232 .map(|(next, _)| *next)242 .map(|(next, _)| *next)
233 .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next));243 .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next));
234 let creation = PageCreation::new(before, Some(&page.title), author)?;244 let before = series_start(new, before)?;
245 let creation = placed(PageCreation::new(before, Some(&page.title), author)?)?;
235 let mut ops = vec![Op::Section(SectionOp::Import {246 let mut ops = vec![Op::Section(SectionOp::Import {
236 creation: creation.clone(),247 creation: creation.clone(),
237 page: page.copy()?,248 page: page.copy()?,
...@@ -252,7 +263,7 @@ pub(crate) fn conflict_page(...@@ -252,7 +263,7 @@ pub(crate) fn conflict_page(
252/// Pages the base and the remote both list that the remote moved: into another series or263/// Pages the base and the remote both list that the remote moved: into another series or
253/// level, or out of base order among the rest. OneNote 2010 and this writer give a moved264/// level, or out of base order among the rest. OneNote 2010 and this writer give a moved
254/// page a series of its own; older builds of this writer moved a page within its series.265/// page a series of its own; older builds of this writer moved a page within its series.
255fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid>> {266pub(crate) fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid>> {
256 let series = (old.series()?, new.series()?);267 let series = (old.series()?, new.series()?);
257 let base: BTreeMap<ExGuid, (usize, u32)> = order(old)?268 let base: BTreeMap<ExGuid, (usize, u32)> = order(old)?
258 .into_iter()269 .into_iter()
...@@ -293,6 +304,26 @@ fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid...@@ -293,6 +304,26 @@ fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid
293 .collect())304 .collect())
294}305}
295306
307/// Where a page made before `before` goes in `section`: before it where it starts a page
308/// series, else before the next page that starts one, or last; a page is made only before a
309/// series' first page.
310fn series_start(section: &mut Section<'_>, before: Option<ExGuid>) -> Result<Option<ExGuid>> {
311 Ok(starting(&order(section)?, &section.series()?, before))
312}
313
314fn starting(
315 listed: &Order,
316 series: &BTreeMap<ExGuid, ExGuid>,
317 before: Option<ExGuid>,
318) -> Option<ExGuid> {
319 let at = listed
320 .iter()
321 .position(|(space, _)| Some(*space) == before)?;
322 (at..listed.len())
323 .find(|&k| k == 0 || series.get(&listed[k - 1].0) != series.get(&listed[k].0))
324 .map(|k| listed[k].0)
325}
326
296/// Applies a queued page-list edit to `local`, the page order as the queue leaves it.327/// Applies a queued page-list edit to `local`, the page order as the queue leaves it.
297fn advance(local: &mut Order, op: &SectionOp) {328fn advance(local: &mut Order, op: &SectionOp) {
298 let insert = |local: &mut Order, page: (ExGuid, u32), before: Option<ExGuid>| {329 let insert = |local: &mut Order, page: (ExGuid, u32), before: Option<ExGuid>| {
...@@ -444,8 +475,10 @@ impl Replay {...@@ -444,8 +475,10 @@ impl Replay {
444 .flat_map(|(_, pages)| pages.into_iter().map(|page| page.space))475 .flat_map(|(_, pages)| pages.into_iter().map(|page| page.space))
445 .collect();476 .collect();
446 let listed = order(new)?;477 let listed = order(new)?;
478 let series = new.series()?;
447 let anchored = |creation: &PageCreation| {479 let anchored = |creation: &PageCreation| {
448 creation.reposition(self.anchor(&listed, creation.space(), creation.before()))480 let anchor = self.anchor(&listed, creation.space(), creation.before());
481 creation.reposition(starting(&listed, &series, anchor))
449 };482 };
450 Ok(match op {483 Ok(match op {
451 SectionOp::Create(creation) => Some(SectionOp::Create(anchored(creation)?)),484 SectionOp::Create(creation) => Some(SectionOp::Create(anchored(creation)?)),
crates/notebook/src/queue.rs+2
...@@ -278,8 +278,10 @@ mod tests {...@@ -278,8 +278,10 @@ mod tests {
278 layout: Default::default(),278 layout: Default::default(),
279 size: None,279 size: None,
280 bytes: Some(Arc::clone(&bytes)),280 bytes: Some(Arc::clone(&bytes)),
281 display: None,
281 alt: None,282 alt: None,
282 background: false,283 background: false,
284 printout: None,
283 })285 })
284 };286 };
285 let space = ExGuid {287 let space = ExGuid {
crates/notebook/src/resolve.rs created+293
...@@ -0,0 +1,293 @@
1//! Merging a version of a section file that a file provider kept beside the file, as iCloud
2//! Drive keeps a commit that lost to another device's (a conflict version, `NSFileVersion`).
3//! The version's changes since the state it last shared with the file replay on the file
4//! (`merge.rs`), with conflict pages where they clash, as a queue replays on a changed
5//! remote.
6//!
7//! Each revision the merge writes is named after the version's revision it merged
8//! (`merged`). A space whose newest revision the file holds, or holds merged, needs nothing;
9//! older ones are where the version and the file last agreed. So a version merged once, by
10//! this device or another, merges as nothing the next time, and two devices merging it at
11//! once write revisions of the same names, which the next merge of the two finds held.
12
13use crate::{PendingEdit, Result, merge};
14use onestore::{
15 Arena, ConflictPage, ExGuid, PageCreation, PageEdit, RevisionIndex, Section, Store,
16 Transaction,
17 op::{Edit, Op, SectionOp, lower_page},
18};
19use sha2::{Digest, Sha256};
20use std::collections::{BTreeMap, BTreeSet};
21
22/// A version of a section file its file provider keeps beside the file.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Version {
25 /// Names the version to `Remote::version` and `Remote::retire`.
26 pub id: String,
27 /// The computer or device that saved it (`localizedNameOfSavingComputer`), which labels
28 /// the conflict pages it gives.
29 pub device: Option<String>,
30}
31
32/// What merging a version into the file comes to.
33pub(crate) enum Merged {
34 /// The file already holds every change the version holds.
35 Held,
36 /// The revisions bringing the version's changes onto the file.
37 Publish(Box<Transaction>),
38 /// Another section, which only shares the file's name.
39 Foreign,
40}
41
42/// The name of the revision a merge writes in a space for the version's revision `rid`, a
43/// different one for each `kind` of space it writes.
44fn merged(rid: ExGuid, kind: &str) -> ExGuid {
45 let digest = Sha256::new()
46 .chain_update(b"snowbound merge ")
47 .chain_update(kind)
48 .chain_update(rid.guid)
49 .chain_update(rid.n.to_le_bytes())
50 .finalize();
51 let mut guid = [0; 16];
52 guid.copy_from_slice(&digest[..16]);
53 ExGuid { guid, n: 1 }
54}
55
56/// Merges `version` into `current`, the file as it stands, `device` naming who saved the
57/// version.
58pub(crate) fn merge(current: &[u8], version: &[u8], device: &str) -> Result<Merged> {
59 let (store, theirs) = (Store::parse(current)?, Store::parse(version)?);
60 let (file, stored) = (
61 RevisionIndex::parse(&store)?,
62 RevisionIndex::parse(&theirs)?,
63 );
64 if file.root != stored.root {
65 return Ok(Merged::Foreign);
66 }
67 // Per space of the version: where it last agreed with the file, and its newest revision.
68 let mut shared = BTreeMap::new();
69 let mut heads = BTreeMap::new();
70 for (space, revisions) in &stored.spaces {
71 let Some(head) = revisions.labels.get(&(ExGuid::default(), 1)).copied() else {
72 continue;
73 };
74 heads.insert(*space, head);
75 let ours = file.spaces.get(space);
76 // Where the version's revision `rid` meets the file: the file holds it, or holds it
77 // merged (a page the file had removed comes back as a copy in a space of its own), or
78 // it is the file's revision merged the other way, the state then the base.
79 let meets = |rid: ExGuid| {
80 let held = ours.is_some_and(|ours| {
81 ours.revisions.contains_key(&rid)
82 || ours.revisions.contains_key(&merged(rid, "page"))
83 }) || file.spaces.contains_key(&ExGuid {
84 guid: merged(rid, "conflict").guid,
85 n: 1,
86 });
87 if held {
88 return Some(rid);
89 }
90 ours?
91 .revisions
92 .keys()
93 .find(|theirs| merged(**theirs, "page") == rid)
94 .copied()
95 };
96 let mut at = Some(head);
97 while let Some(rid) = at {
98 if let Some(base) = meets(rid) {
99 shared.insert(*space, base);
100 break;
101 }
102 at = revisions
103 .revisions
104 .get(&rid)
105 .and_then(|revision| revision.dependency);
106 }
107 }
108 let root = file.root;
109 // Without a state of the page list both share, the version's list counts as unchanged.
110 let head = |space: &ExGuid| heads.get(space).copied();
111 if let std::collections::btree_map::Entry::Vacant(entry) = shared.entry(root) {
112 entry.insert(head(&root).ok_or(onestore::Error {
113 offset: 0,
114 message: "The version has no page list",
115 })?);
116 }
117 let changed: BTreeSet<ExGuid> = heads
118 .iter()
119 .filter(|(space, head)| shared.get(space) != Some(head))
120 .map(|(space, _)| *space)
121 .collect();
122 let arenas = (Arena::default(), Arena::default(), Arena::default());
123 let mut ancestor =
124 Section::open_at(&arenas.0, vec![version.to_vec(), current.to_vec()], &shared)?;
125 let mut theirs = Section::open(&arenas.1, version.to_vec())?;
126 let ours = Section::open(&arenas.2, current.to_vec())?;
127 let stored_here = |space: &ExGuid| file.spaces.contains_key(space);
128
129 let listed = theirs.pages()?;
130 let before: Vec<ExGuid> = ancestor
131 .pages()?
132 .into_iter()
133 .map(|(space, ..)| space)
134 .collect();
135 let moved = merge::moved(&mut ancestor, &mut theirs)?;
136 let mut ops = Vec::new();
137 // From the last page to the first, so that each goes before a page already placed.
138 for (at, (space, _, level)) in listed.iter().enumerate().rev() {
139 let next = listed.get(at + 1).map(|(next, ..)| *next);
140 if !stored_here(space) {
141 let page = theirs.page(*space)?;
142 let mut creation = PageCreation::new(next, titled(&page), device)?;
143 if let (Some(identity), Some(created)) = (page.identity, page.created) {
144 creation = creation.keeping(identity, created)?;
145 }
146 // The page keeps its identities, so that each merge of the version makes it alike.
147 let (creation, page) = match space.n {
148 1 => (creation.in_space(space.guid)?, page),
149 _ => (creation, page.copy()?),
150 };
151 let space = creation.space();
152 ops.push(Op::Section(SectionOp::Import { creation, page }));
153 if *level > 1 {
154 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::set_level(
155 space, *level,
156 )?])));
157 }
158 } else if moved.contains(space) {
159 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
160 *space, next, *level,
161 )?])));
162 }
163 }
164 let kept: BTreeSet<ExGuid> = listed.iter().map(|(space, ..)| *space).collect();
165 let conflicts =
166 |section: &mut Section<'_>| -> Result<BTreeMap<ExGuid, (ExGuid, ConflictPage)>> {
167 Ok(section
168 .conflicts()?
169 .into_iter()
170 .flat_map(|(of, pages)| pages.into_iter().map(move |page| (page.space, (of, page))))
171 .collect())
172 };
173 let (was, now) = (conflicts(&mut ancestor)?, conflicts(&mut theirs)?);
174 let removed: Vec<ExGuid> = before
175 .iter()
176 .filter(|space| !kept.contains(space))
177 .chain(was.keys().filter(|space| !now.contains_key(space)))
178 .copied()
179 .collect();
180 if !removed.is_empty() {
181 ops.push(Op::Section(SectionOp::Delete(removed)));
182 }
183 // Pages both hold with no state in common, or that do not lower to ops: the version's is
184 // kept beside the file's.
185 let mut unrelated = BTreeSet::new();
186 for (space, ..) in &listed {
187 if !changed.contains(space) || !stored_here(space) {
188 continue;
189 }
190 let page = theirs.page(*space)?;
191 let lowered = match shared.get(space) {
192 Some(_) => lower_page(&ancestor.page(*space)?, &page).ok(),
193 None if ours.page(*space).ok().as_ref() == Some(&page) => Some(Vec::new()),
194 None => None,
195 };
196 match lowered {
197 Some(lowered) => {
198 ops.extend(lowered.into_iter().map(|op| Op::Page { space: *space, op }))
199 }
200 None => {
201 unrelated.insert(*space);
202 }
203 }
204 }
205 for (space, (of, conflict)) in now {
206 if stored_here(&space) || was.contains_key(&space) {
207 continue;
208 }
209 let page = theirs.page(space)?;
210 let user = Some(conflict.user).filter(|user| !user.is_empty());
211 ops.push(Op::Section(SectionOp::Conflict {
212 of,
213 creation: PageCreation::new(None, titled(&page), user.as_deref().unwrap_or(device))?
214 .in_space(space.guid)?,
215 page,
216 objects: conflict.objects,
217 }));
218 }
219 let edits = [PendingEdit {
220 id: 0,
221 author: device.to_owned(),
222 edit: Edit {
223 at: crate::now(),
224 ops,
225 },
226 }];
227
228 // As a queue rebases (`working::rebase`): pages the file already holds as the version
229 // leaves them are done; the rest that cannot replay become conflict pages.
230 let mut converged = BTreeSet::new();
231 loop {
232 let arena = Arena::default();
233 let mut new = Section::open(&arena, current.to_vec())?;
234 let replayed = merge::rebase(&mut ancestor, &mut new, &edits, &converged)?;
235 let mut clashes: BTreeMap<ExGuid, BTreeSet<ExGuid>> = replayed
236 .conflicts
237 .into_iter()
238 .map(|(space, (_, objects))| (space, objects))
239 .collect();
240 let settled: Vec<ExGuid> = clashes
241 .keys()
242 .filter(|space| {
243 matches!((ours.page(**space), theirs.page(**space)), (Ok(a), Ok(b)) if a == b)
244 })
245 .copied()
246 .collect();
247 if !settled.is_empty() {
248 converged.extend(settled);
249 continue;
250 }
251 for space in &unrelated {
252 clashes.entry(*space).or_default();
253 }
254 let mut names: BTreeMap<ExGuid, ExGuid> = changed
255 .iter()
256 .filter_map(|space| Some((*space, merged(head(space)?, "page"))))
257 .collect();
258 for (space, objects) in &clashes {
259 let Ok(page) = theirs.page(*space) else {
260 continue;
261 };
262 let Some(rid) = head(space) else {
263 continue;
264 };
265 // Named after the version's page, so that every merge of it makes the same page.
266 let guid = merged(rid, "conflict").guid;
267 merge::conflict_page(
268 &mut new,
269 &mut theirs,
270 &replayed.moved,
271 *space,
272 &page,
273 device,
274 objects,
275 crate::now(),
276 Some(guid),
277 )?;
278 names.insert(ExGuid { guid, n: 1 }, merged(rid, "page"));
279 }
280 return Ok(match new.seal_as(&names)? {
281 Some(transaction) => Merged::Publish(Box::new(transaction)),
282 None => Merged::Held,
283 });
284 }
285}
286
287/// A page's title, where it has one, as a page made for it takes.
288fn titled(page: &onestore::page::Page) -> Option<&str> {
289 page.objects
290 .iter()
291 .any(|object| matches!(object, onestore::page::PageObject::Title(_)))
292 .then_some(page.title.as_str())
293}
crates/notebook/src/session.rs+181-17
...@@ -82,11 +82,17 @@ pub trait Storage: Send + Sync {...@@ -82,11 +82,17 @@ pub trait Storage: Send + Sync {
82 fn location(&self) -> String;82 fn location(&self) -> String;
83 fn exists(&self, path: &str) -> bool;83 fn exists(&self, path: &str) -> bool;
84 fn read(&self, path: &str) -> Result<Vec<u8>>;84 fn read(&self, path: &str) -> Result<Vec<u8>>;
85 /// Reads a file of at most `limit` bytes as it stands, whatever it holds.
86 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>>;
85 /// Creates a file holding `bytes`; an existing file is an error.87 /// Creates a file holding `bytes`; an existing file is an error.
86 fn create(&self, path: &str, bytes: &[u8]) -> Result<()>;88 fn create(&self, path: &str, bytes: &[u8]) -> Result<()>;
87 fn create_directory(&self, path: &str) -> Result<()>;89 fn create_directory(&self, path: &str) -> Result<()>;
90 /// Gives a file or directory the Windows hidden attribute, where the storage keeps one.
91 fn hide(&self, path: &str) -> Result<()>;
88 /// Renames or moves a file or directory; an existing target is an error.92 /// Renames or moves a file or directory; an existing target is an error.
89 fn rename(&self, from: &str, to: &str) -> Result<()>;93 fn rename(&self, from: &str, to: &str) -> Result<()>;
94 /// Renames a file over another, replacing it.
95 fn replace(&self, from: &str, to: &str) -> Result<()>;
90 /// Deletes a file or an empty directory.96 /// Deletes a file or an empty directory.
91 fn delete(&self, path: &str) -> Result<()>;97 fn delete(&self, path: &str) -> Result<()>;
92 /// Names a section or TOC file for its notebook, as `onestore::place`.98 /// Names a section or TOC file for its notebook, as `onestore::place`.
...@@ -137,8 +143,24 @@ impl Storage for Directory {...@@ -137,8 +143,24 @@ impl Storage for Directory {
137 Ok(onestore::read_file(self.path(path))?)143 Ok(onestore::read_file(self.path(path))?)
138 }144 }
139145
146 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
147 use std::io::Read;
148 let mut bytes = Vec::new();
149 std::fs::File::open(self.path(path))?
150 .take(limit as u64 + 1)
151 .read_to_end(&mut bytes)?;
152 if bytes.len() > limit {
153 return Err(io::Error::from(io::ErrorKind::FileTooLarge).into());
154 }
155 Ok(bytes)
156 }
157
140 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {158 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
141 std::fs::File::create_new(self.path(path))?.write_all(bytes)?;159 let path = self.path(path);
160 if discover::placeholder(&path).is_some_and(|placeholder| placeholder.exists()) {
161 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
162 }
163 std::fs::File::create_new(path)?.write_all(bytes)?;
142 Ok(())164 Ok(())
143 }165 }
144166
...@@ -146,10 +168,32 @@ impl Storage for Directory {...@@ -146,10 +168,32 @@ impl Storage for Directory {
146 Ok(std::fs::create_dir(self.path(path))?)168 Ok(std::fs::create_dir(self.path(path))?)
147 }169 }
148170
171 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted.
172 fn hide(&self, path: &str) -> Result<()> {
173 #[cfg(target_vendor = "apple")]
174 {
175 use nix::sys::stat::{FileFlag, stat};
176 let path = self.path(path);
177 let flags = stat(&path).map_err(io::Error::from)?.st_flags;
178 let flags = FileFlag::from_bits_retain(flags);
179 if !flags.contains(FileFlag::UF_HIDDEN) {
180 nix::unistd::chflags(&path, flags | FileFlag::UF_HIDDEN)
181 .map_err(io::Error::from)?;
182 }
183 }
184 #[cfg(not(target_vendor = "apple"))]
185 let _ = path;
186 Ok(())
187 }
188
149 fn rename(&self, from: &str, to: &str) -> Result<()> {189 fn rename(&self, from: &str, to: &str) -> Result<()> {
150 Ok(std::fs::rename(self.path(from), self.path(to))?)190 Ok(std::fs::rename(self.path(from), self.path(to))?)
151 }191 }
152192
193 fn replace(&self, from: &str, to: &str) -> Result<()> {
194 Ok(std::fs::rename(self.path(from), self.path(to))?)
195 }
196
153 fn delete(&self, path: &str) -> Result<()> {197 fn delete(&self, path: &str) -> Result<()> {
154 let path = self.path(path);198 let path = self.path(path);
155 if path.is_dir() {199 if path.is_dir() {
...@@ -201,7 +245,7 @@ impl Storage for Share {...@@ -201,7 +245,7 @@ impl Storage for Share {
201 }245 }
202246
203 fn location(&self) -> String {247 fn location(&self) -> String {
204 format!("{}/{}", self.client.share(), self.root)248 self.client.location(&self.root)
205 }249 }
206250
207 fn exists(&self, path: &str) -> bool {251 fn exists(&self, path: &str) -> bool {
...@@ -217,6 +261,10 @@ impl Storage for Share {...@@ -217,6 +261,10 @@ impl Storage for Share {
217 .read_storage(&self.path(path), 256 * 1024 * 1024)?)261 .read_storage(&self.path(path), 256 * 1024 * 1024)?)
218 }262 }
219263
264 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
265 Ok(self.client.read_asset(&self.path(path), limit)?)
266 }
267
220 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {268 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
221 Ok(self.client.create(&self.path(path), bytes)?)269 Ok(self.client.create(&self.path(path), bytes)?)
222 }270 }
...@@ -225,10 +273,18 @@ impl Storage for Share {...@@ -225,10 +273,18 @@ impl Storage for Share {
225 Ok(self.client.create_directory(&self.path(path))?)273 Ok(self.client.create_directory(&self.path(path))?)
226 }274 }
227275
276 fn hide(&self, path: &str) -> Result<()> {
277 Ok(self.client.hide(&self.path(path))?)
278 }
279
228 fn rename(&self, from: &str, to: &str) -> Result<()> {280 fn rename(&self, from: &str, to: &str) -> Result<()> {
229 Ok(self.client.rename(&self.path(from), &self.path(to))?)281 Ok(self.client.rename(&self.path(from), &self.path(to))?)
230 }282 }
231283
284 fn replace(&self, from: &str, to: &str) -> Result<()> {
285 Ok(self.client.replace(&self.path(from), &self.path(to))?)
286 }
287
232 fn delete(&self, path: &str) -> Result<()> {288 fn delete(&self, path: &str) -> Result<()> {
233 Ok(self.client.delete(&self.path(path))?)289 Ok(self.client.delete(&self.path(path))?)
234 }290 }
...@@ -266,6 +322,10 @@ pub struct Notebook {...@@ -266,6 +322,10 @@ pub struct Notebook {
266}322}
267323
268impl Notebook {324impl Notebook {
325 /// The colour OneNote 2010 gives each new notebook beside its default one, COLORREF
326 /// (`corpus/notebook-management/native/new-notebook`).
327 pub const NEW_COLOR: u32 = 0x00aeba91;
328
269 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {329 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {
270 let root = root.as_ref().canonicalize()?;330 let root = root.as_ref().canonicalize()?;
271 Self::with(Box::new(Directory(root.clone())), Some(root), cache)331 Self::with(Box::new(Directory(root.clone())), Some(root), cache)
...@@ -297,7 +357,7 @@ impl Notebook {...@@ -297,7 +357,7 @@ impl Notebook {
297 cache: impl AsRef<Path>,357 cache: impl AsRef<Path>,
298 ) -> Result<Self> {358 ) -> Result<Self> {
299 let root = root.replace('\\', "/");359 let root = root.replace('\\', "/");
300 let copies = cache.as_ref().join("smb");360 let copies = crate::location::folder(cache.as_ref(), &client.location(&root));
301 Self::with(361 Self::with(
302 Box::new(Share {362 Box::new(Share {
303 client,363 client,
...@@ -327,6 +387,31 @@ impl Notebook {...@@ -327,6 +387,31 @@ impl Notebook {
327 .and_then(|bytes| serde_json::from_slice(&bytes).ok())387 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
328 .unwrap_or_default();388 .unwrap_or_default();
329 let catalog = storage.discover(&mut read, LIMITS)?;389 let catalog = storage.discover(&mut read, LIMITS)?;
390 let location = storage.location();
391 let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new();
392 let mut folders = vec![&catalog];
393 while let Some(folder) = folders.pop() {
394 for section in &folder.sections {
395 let identity = match (&root, &section.state) {
396 (Some(_), discover::SectionState::Readable { document, .. }) => *document,
397 (None, discover::SectionState::Readable { .. }) => section.file_id,
398 _ => continue,
399 };
400 claims
401 .entry(replica_location(&location, section))
402 .or_default()
403 .push((identity, &section.path));
404 }
405 folders.extend(&folder.groups);
406 }
407 for (at, sections) in &claims {
408 let identities: Vec<[u8; 16]> =
409 sections.iter().map(|(identity, _)| *identity).collect();
410 crate::location::claim(&cache, at, &identities, |identity| {
411 let (_, path) = sections.iter().find(|(held, _)| held == identity)?;
412 Stamp::of(&storage.read(path).ok()?).ok()
413 })?;
414 }
330 let notebook = Self {415 let notebook = Self {
331 storage,416 storage,
332 root,417 root,
...@@ -355,6 +440,30 @@ impl Notebook {...@@ -355,6 +440,30 @@ impl Notebook {
355 &self.catalog440 &self.catalog
356 }441 }
357442
443 /// The tags the notebook draws with Snowbound's art (`crate::sidecar`); none where it
444 /// maps none.
445 pub fn tag_art(&self) -> Result<Vec<crate::sidecar::TagMapping>> {
446 crate::sidecar::mappings(&*self.storage)
447 }
448
449 /// The picture a mapping names, once its bytes match its name.
450 pub fn tag_art_file(&self, art: &str) -> Result<Vec<u8>> {
451 crate::sidecar::art(&*self.storage, art)
452 }
453
454 /// Maps tag `name` with symbol `shape` to picture `bytes`, a PNG or SVG as `extension`
455 /// says, making the notebook's hidden `.snowbound` folder where it has none. Returns the
456 /// notebook's mappings as they then stand.
457 pub fn map_tag_art(
458 &self,
459 name: &str,
460 shape: u16,
461 bytes: &[u8],
462 extension: &str,
463 ) -> Result<Vec<crate::sidecar::TagMapping>> {
464 crate::sidecar::map(&*self.storage, name, shape, bytes, extension)
465 }
466
358 /// Rereads the notebook and reports what changed since the last catalog, keyed by467 /// Rereads the notebook and reports what changed since the last catalog, keyed by
359 /// file identity so a renamed or moved section stays the same section. A failed read468 /// file identity so a renamed or moved section stays the same section. A failed read
360 /// keeps the previous catalog: an unreachable notebook is not an empty one.469 /// keeps the previous catalog: an unreachable notebook is not an empty one.
...@@ -941,24 +1050,32 @@ impl Notebook {...@@ -941,24 +1050,32 @@ impl Notebook {
941 Ok(())1050 Ok(())
942 }1051 }
9431052
944 /// Where the replica of the section at catalog `path` lives: named by the section's1053 /// Where the replica of the section at catalog `path` lives, in the notebook's
945 /// document identity in a mounted notebook (`Section::open`), by its file identity under1054 /// `location::folder`: named by the section's document identity in a mounted notebook
946 /// `smb` on a share. It exists once the section has been opened.1055 /// (`Section::open`), by its file identity on a share. It exists once the section has
1056 /// been opened.
947 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {1057 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {
948 let section = self.section_path(path)?;1058 let section = self.section_path(path)?;
1059 let folder = self.replica_folder(section);
949 Ok(match (&self.root, &section.state) {1060 Ok(match (&self.root, &section.state) {
950 (Some(_), discover::SectionState::Readable { document, .. }) => {1061 (Some(_), discover::SectionState::Readable { document, .. }) => {
951 replica_file(&self.cache, document)1062 replica_file(&folder, document)
952 }1063 }
953 (Some(_), _) => {1064 (Some(_), _) => {
954 let image = self.storage.read(&section.path)?;1065 let image = self.storage.read(&section.path)?;
955 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;1066 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;
956 replica_file(&self.cache, &root.guid)1067 replica_file(&folder, &root.guid)
957 }1068 }
958 (None, _) => replica_file(&self.cache.join("smb"), &section.file_id),1069 (None, _) => replica_file(&folder, &section.file_id),
959 })1070 })
960 }1071 }
9611072
1073 /// The `location::folder` holding the replica of `section`.
1074 fn replica_folder(&self, section: &discover::Section) -> PathBuf {
1075 let location = replica_location(&self.storage.location(), section);
1076 crate::location::folder(&self.cache, &location)
1077 }
1078
962 /// Every readable section, for `Background::watch`, handing on the files the last1079 /// Every readable section, for `Background::watch`, handing on the files the last
963 /// discovery read so that each is read once.1080 /// discovery read so that each is read once.
964 pub fn replicas(&mut self) -> Vec<Known> {1081 pub fn replicas(&mut self) -> Vec<Known> {
...@@ -1040,7 +1157,8 @@ impl Notebook {...@@ -1040,7 +1157,8 @@ impl Notebook {
1040 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1157 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1041 notify: impl Fn() + Send + 'static,1158 notify: impl Fn() + Send + 'static,
1042 ) -> Result<Section> {1159 ) -> Result<Section> {
1043 let path = self.section_path(path)?.path.clone();1160 let section = self.section_path(path)?;
1161 let (path, replicas) = (section.path.clone(), self.replica_folder(section));
1044 let Some(root) = &self.root else {1162 let Some(root) = &self.root else {
1045 return Err(io::Error::new(1163 return Err(io::Error::new(
1046 io::ErrorKind::Unsupported,1164 io::ErrorKind::Unsupported,
...@@ -1053,7 +1171,11 @@ impl Notebook {...@@ -1053,7 +1171,11 @@ impl Notebook {
1053 if !file.starts_with(root) {1171 if !file.starts_with(root) {
1054 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());1172 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
1055 }1173 }
1056 Section::open_with(file, &self.cache, connect, notify)1174 let replica = |identity: &[u8; 16], _: &[u8]| {
1175 std::fs::create_dir_all(&replicas)?;
1176 Ok(replica_file(&replicas, identity))
1177 };
1178 Section::open_in(file, replica, connect, notify)
1057 }1179 }
1058}1180}
10591181
...@@ -1156,6 +1278,17 @@ fn catalog_path(folder: &str, name: &str) -> String {...@@ -1156,6 +1278,17 @@ fn catalog_path(folder: &str, name: &str) -> String {
1156 }1278 }
1157}1279}
11581280
1281/// The location keying the replica of `section` in the notebook at `notebook`: the notebook's,
1282/// so that a section renamed or moved within it keeps its replica, or for a copy of another
1283/// section of the notebook its own file's.
1284fn replica_location(notebook: &str, section: &discover::Section) -> String {
1285 if section.copy {
1286 format!("{notebook}/{}", section.path)
1287 } else {
1288 notebook.to_owned()
1289 }
1290}
1291
1159/// The replica in `cache` of the section `identity` names.1292/// The replica in `cache` of the section `identity` names.
1160pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {1293pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
1161 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();1294 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();
...@@ -1212,6 +1345,10 @@ pub enum SyncState {...@@ -1212,6 +1345,10 @@ pub enum SyncState {
1212 NotConnected,1345 NotConnected,
1213 /// The file cannot be written where it is stored.1346 /// The file cannot be written where it is stored.
1214 ReadOnly,1347 ReadOnly,
1348 /// The section is password protected, which Snowbound cannot open.
1349 Protected,
1350 /// The file is stably not a section Snowbound can read.
1351 Unreadable,
1215 Failed,1352 Failed,
1216}1353}
12171354
...@@ -1221,6 +1358,8 @@ impl SyncStatus {...@@ -1221,6 +1358,8 @@ impl SyncStatus {
1221 match self.error.as_ref().map(io::Error::kind) {1358 match self.error.as_ref().map(io::Error::kind) {
1222 Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly,1359 Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly,
1223 Some(WouldBlock | ResourceBusy) => SyncState::InUse,1360 Some(WouldBlock | ResourceBusy) => SyncState::InUse,
1361 Some(Unsupported) => SyncState::Protected,
1362 Some(InvalidData) => SyncState::Unreadable,
1224 Some(1363 Some(
1225 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected1364 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected
1226 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe1365 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe
...@@ -1249,9 +1388,9 @@ pub struct Section {...@@ -1249,9 +1388,9 @@ pub struct Section {
1249}1388}
12501389
1251impl Section {1390impl Section {
1252 /// Opens the section file through a replica in `cache`, creating the replica from the1391 /// Opens the lone section file through a replica in `cache`, in the file's
1253 /// file on first use and converting an older one. `notify` runs on a background thread1392 /// `location::folder`, creating the replica from the file on first use and converting an
1254 /// whenever an event is available.1393 /// older one. `notify` runs on a background thread whenever an event is available.
1255 pub fn open(1394 pub fn open(
1256 file: impl AsRef<Path>,1395 file: impl AsRef<Path>,
1257 cache: impl AsRef<Path>,1396 cache: impl AsRef<Path>,
...@@ -1266,15 +1405,32 @@ impl Section {...@@ -1266,15 +1405,32 @@ impl Section {
1266 pub fn open_with<R: Remote + 'static>(1405 pub fn open_with<R: Remote + 'static>(
1267 file: impl AsRef<Path>,1406 file: impl AsRef<Path>,
1268 cache: impl AsRef<Path>,1407 cache: impl AsRef<Path>,
1269 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1408 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1270 notify: impl Fn() + Send + 'static,1409 notify: impl Fn() + Send + 'static,
1271 ) -> Result<Self> {1410 ) -> Result<Self> {
1272 let file = file.as_ref().canonicalize()?;1411 let file = file.as_ref().canonicalize()?;
1412 let location = file.to_string_lossy().into_owned();
1413 let replicas = |identity: &[u8; 16], source: &[u8]| {
1414 let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| {
1415 Stamp::of(source).ok()
1416 })?;
1417 Ok(replica_file(&folder, identity))
1418 };
1419 Self::open_in(file, replicas, connect, notify)
1420 }
1421
1422 /// Opens the canonical section `file` through the replica `replica` names for its document
1423 /// identity and image.
1424 fn open_in<R: Remote + 'static>(
1425 file: PathBuf,
1426 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,
1427 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1428 notify: impl Fn() + Send + 'static,
1429 ) -> Result<Self> {
1273 let source = connect(&file)?.read()?;1430 let source = connect(&file)?.read()?;
1274 let store = Store::parse(&source)?;1431 let store = Store::parse(&source)?;
1275 let identity = RevisionIndex::parse(&store)?.root;1432 let identity = RevisionIndex::parse(&store)?.root;
1276 std::fs::create_dir_all(&cache)?;1433 let cache = replica(&identity.guid, &source)?;
1277 let cache = replica_file(cache.as_ref(), &identity.guid);
1278 let replica = if cache.exists() {1434 let replica = if cache.exists() {
1279 Replica::open(&cache)?1435 Replica::open(&cache)?
1280 } else {1436 } else {
...@@ -1556,6 +1712,14 @@ impl Section {...@@ -1556,6 +1712,14 @@ impl Section {
1556 }1712 }
1557 }1713 }
15581714
1715 /// Publishes local edits once `pause` passes without another (`SyncWorker::set_pause`),
1716 /// as a notebook on a cloud drive does; `wake` publishes them at once.
1717 pub fn set_pause(&self, pause: Duration) {
1718 if let Some(worker) = &self.worker {
1719 worker.set_pause(pause);
1720 }
1721 }
1722
1559 /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until1723 /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until
1560 /// working online again (OneNote's Work Offline).1724 /// working online again (OneNote's Work Offline).
1561 pub fn set_offline(&self, offline: bool) {1725 pub fn set_offline(&self, offline: bool) {
crates/notebook/src/sidecar.rs created+166
...@@ -0,0 +1,166 @@
1//! Snowbound's own files in a notebook folder, kept in `.snowbound`. OneNote 2010 makes a
2//! section group of every subfolder but one with the Windows hidden attribute, so the
3//! folder takes the attribute when made, and again whenever Snowbound writes to it; the dot
4//! name hides it on Samba's defaults, macOS and Linux as well. Each feature keeps its own
5//! names inside, and a notebook without the folder is whole.
6//!
7//! Tag art: `tags.json` maps a tag, by the name and symbol its definition stores, to a
8//! picture in `tags/` named for its content, `<SHA-256>.png` or `.svg`, which is never
9//! rewritten. A writer rereads the mapping, merges its own and replaces the file, then reads
10//! it back and merges again until its own holds. The merge keeps every tag either side
11//! mapped; where both mapped one tag, the later mapping wins, then the greater art name, so
12//! writers agree whatever order they read in.
13
14use crate::{Result, session::Storage};
15use serde::{Deserialize, Serialize};
16use std::io;
17
18const FOLDER: &str = ".snowbound";
19const MAPPING: &str = ".snowbound/tags.json";
20const ART: &str = ".snowbound/tags";
21/// The most bytes read of a mapping or a picture.
22pub const LIMIT: usize = 1 << 20;
23
24/// A tag drawn with art of Snowbound's.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct TagMapping {
27 /// The tag's name and symbol, as its definition stores them.
28 pub name: String,
29 pub shape: u16,
30 /// Its picture in `tags/`.
31 pub art: String,
32 /// When it was mapped, as a FILETIME.
33 pub mapped: u64,
34}
35
36/// The name `tags/` keeps a picture under: its SHA-256 and its extension, `png` or `svg`.
37pub fn art_name(bytes: &[u8], extension: &str) -> String {
38 let digest = <sha2::Sha256 as sha2::Digest>::digest(bytes);
39 let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
40 format!("{hex}.{extension}")
41}
42
43/// Whether `name` could be a picture's in `tags/`, and so names nothing else.
44fn art_named(name: &str) -> bool {
45 name.split_once('.').is_some_and(|(hash, extension)| {
46 hash.len() == 64
47 && hash
48 .bytes()
49 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
50 && matches!(extension, "png" | "svg")
51 })
52}
53
54/// Merges `mappings` into `into`, by the rule the module describes.
55pub fn merge(into: &mut Vec<TagMapping>, mappings: impl IntoIterator<Item = TagMapping>) {
56 for mapping in mappings {
57 match into
58 .iter_mut()
59 .find(|kept| kept.name == mapping.name && kept.shape == mapping.shape)
60 {
61 Some(kept) => {
62 if (mapping.mapped, &mapping.art) > (kept.mapped, &kept.art) {
63 *kept = mapping;
64 }
65 }
66 None => into.push(mapping),
67 }
68 }
69}
70
71/// The mappings `tags.json` holds: none without it, and none it holds unreadably.
72pub(crate) fn mappings(storage: &dyn Storage) -> Result<Vec<TagMapping>> {
73 let bytes = match storage.read_file(MAPPING, LIMIT) {
74 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
75 return Ok(Vec::new());
76 }
77 bytes => bytes?,
78 };
79 let values: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap_or_default();
80 let mut mappings = Vec::new();
81 merge(
82 &mut mappings,
83 values
84 .into_iter()
85 .filter_map(|value| serde_json::from_value::<TagMapping>(value).ok())
86 .filter(|mapping| art_named(&mapping.art)),
87 );
88 Ok(mappings)
89}
90
91/// Picture `art` from `tags/`, once its bytes match its name.
92pub(crate) fn art(storage: &dyn Storage, art: &str) -> Result<Vec<u8>> {
93 let extension = art.rsplit('.').next().unwrap_or_default();
94 if !art_named(art) {
95 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
96 }
97 let bytes = storage.read_file(&format!("{ART}/{art}"), LIMIT)?;
98 if art_name(&bytes, extension) != art {
99 return Err(io::Error::from(io::ErrorKind::InvalidData).into());
100 }
101 Ok(bytes)
102}
103
104/// Maps tag `name` with symbol `shape` to picture `bytes`, a PNG or SVG as `extension`
105/// says, now; returns the mappings as they then stand.
106pub(crate) fn map(
107 storage: &dyn Storage,
108 name: &str,
109 shape: u16,
110 bytes: &[u8],
111 extension: &str,
112) -> Result<Vec<TagMapping>> {
113 if !matches!(extension, "png" | "svg") || bytes.len() > LIMIT {
114 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
115 }
116 let mapping = TagMapping {
117 name: name.to_owned(),
118 shape,
119 art: art_name(bytes, extension),
120 mapped: crate::now(),
121 };
122 for folder in [FOLDER, ART] {
123 match storage.create_directory(folder) {
124 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
125 created => created?,
126 }
127 }
128 storage.hide(FOLDER)?;
129 let picture = format!("{ART}/{}", mapping.art);
130 if !storage.exists(&picture) {
131 let written = temporary(&picture);
132 storage.create(&written, bytes)?;
133 // Another writer may have kept the same picture meanwhile.
134 if let Err(error) = storage.rename(&written, &picture) {
135 storage.delete(&written)?;
136 if !storage.exists(&picture) {
137 return Err(error);
138 }
139 }
140 }
141 for _ in 0..3 {
142 let mut merged = mappings(storage)?;
143 merge(&mut merged, [mapping.clone()]);
144 let written = temporary(MAPPING);
145 let json = serde_json::to_vec_pretty(&merged).map_err(io::Error::from)?;
146 storage.create(&written, &json)?;
147 storage.replace(&written, MAPPING)?;
148 let kept = mappings(storage)?;
149 let mut held = kept.clone();
150 merge(&mut held, [mapping.clone()]);
151 if held == kept {
152 return Ok(kept);
153 }
154 }
155 Err(io::Error::from(io::ErrorKind::ResourceBusy).into())
156}
157
158/// A name beside `path` no other writer picks.
159fn temporary(path: &str) -> String {
160 use std::hash::{BuildHasher, RandomState};
161 let unique = RandomState::new().hash_one((std::process::id(), crate::now()));
162 format!("{path}.{unique:016x}.tmp")
163}
164
165#[cfg(test)]
166mod tests;
crates/notebook/src/sidecar/tests.rs created+294
...@@ -0,0 +1,294 @@
1use super::*;
2use crate::{Error, discover};
3use onestore::Transaction;
4use std::{
5 collections::{BTreeMap, BTreeSet},
6 sync::Mutex,
7};
8
9fn mapping(name: &str, shape: u16, art: &str, mapped: u64) -> TagMapping {
10 TagMapping {
11 name: name.into(),
12 shape,
13 art: art_name(art.as_bytes(), "png"),
14 mapped,
15 }
16}
17
18#[test]
19fn merging_keeps_every_tag_and_each_tags_later_mapping() {
20 let ours = vec![
21 mapping("Launch", 13, "rocket", 10),
22 mapping("Bug", 17, "bug", 5),
23 ];
24 let theirs = vec![
25 mapping("Launch", 13, "flame", 20),
26 mapping("Launch", 36, "circle", 1),
27 mapping("Bug", 17, "beetle", 5),
28 ];
29 let mut here = ours.clone();
30 merge(&mut here, theirs.clone());
31 let mut there = theirs;
32 merge(&mut there, ours);
33 let sorted = |mut mappings: Vec<TagMapping>| {
34 mappings.sort_by(|a, b| (&a.name, a.shape).cmp(&(&b.name, b.shape)));
35 mappings
36 };
37 assert_eq!(sorted(here.clone()), sorted(there));
38 let art = |name: &str, shape| {
39 here.iter()
40 .find(|mapping| mapping.name == name && mapping.shape == shape)
41 .map(|mapping| mapping.art.clone())
42 };
43 assert_eq!(here.len(), 3);
44 assert_eq!(art("Launch", 13), Some(art_name(b"flame", "png")));
45 assert_eq!(art("Launch", 36), Some(art_name(b"circle", "png")));
46 // Mapped at once, the greater name wins on either side.
47 let tied = [art_name(b"bug", "png"), art_name(b"beetle", "png")];
48 assert_eq!(art("Bug", 17).as_ref(), tied.iter().max());
49 let before = here.clone();
50 merge(&mut here, before.clone());
51 assert_eq!(here, before);
52}
53
54/// A notebook folder in memory, as a share would hold it.
55#[derive(Default)]
56struct Folder {
57 files: Mutex<BTreeMap<String, Vec<u8>>>,
58 folders: Mutex<BTreeSet<String>>,
59 hidden: Mutex<BTreeSet<String>>,
60 /// Pictures renamed into place.
61 kept: Mutex<Vec<String>>,
62 /// A mapping another writer, having read before this one wrote, puts in place just after.
63 race: Mutex<Option<Vec<u8>>>,
64}
65
66fn missing() -> Error {
67 io::Error::from(io::ErrorKind::NotFound).into()
68}
69
70impl Folder {
71 fn parent_exists(&self, path: &str) -> bool {
72 path.rsplit_once('/')
73 .is_none_or(|(parent, _)| self.folders.lock().unwrap().contains(parent))
74 }
75}
76
77impl Storage for Folder {
78 fn discover(&self, _: &mut discover::Cache, _: discover::Limits) -> Result<discover::Folder> {
79 unimplemented!()
80 }
81
82 fn location(&self) -> String {
83 "memory".into()
84 }
85
86 fn exists(&self, path: &str) -> bool {
87 self.files.lock().unwrap().contains_key(path) || self.folders.lock().unwrap().contains(path)
88 }
89
90 fn read(&self, _: &str) -> Result<Vec<u8>> {
91 unimplemented!()
92 }
93
94 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
95 let bytes = self
96 .files
97 .lock()
98 .unwrap()
99 .get(path)
100 .cloned()
101 .ok_or_else(missing)?;
102 assert!(bytes.len() <= limit);
103 Ok(bytes)
104 }
105
106 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
107 if !self.parent_exists(path) {
108 return Err(missing());
109 }
110 let mut files = self.files.lock().unwrap();
111 if files.contains_key(path) {
112 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
113 }
114 files.insert(path.into(), bytes.to_vec());
115 Ok(())
116 }
117
118 fn create_directory(&self, path: &str) -> Result<()> {
119 if !self.folders.lock().unwrap().insert(path.into()) {
120 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
121 }
122 Ok(())
123 }
124
125 fn hide(&self, path: &str) -> Result<()> {
126 assert!(self.exists(path));
127 self.hidden.lock().unwrap().insert(path.into());
128 Ok(())
129 }
130
131 fn rename(&self, from: &str, to: &str) -> Result<()> {
132 let mut files = self.files.lock().unwrap();
133 if files.contains_key(to) {
134 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
135 }
136 let bytes = files.remove(from).ok_or_else(missing)?;
137 files.insert(to.into(), bytes);
138 self.kept.lock().unwrap().push(to.into());
139 Ok(())
140 }
141
142 fn replace(&self, from: &str, to: &str) -> Result<()> {
143 let mut files = self.files.lock().unwrap();
144 let bytes = files.remove(from).ok_or_else(missing)?;
145 files.insert(to.into(), bytes);
146 if let Some(theirs) = self.race.lock().unwrap().take() {
147 files.insert(to.into(), theirs);
148 }
149 Ok(())
150 }
151
152 fn delete(&self, path: &str) -> Result<()> {
153 self.files
154 .lock()
155 .unwrap()
156 .remove(path)
157 .ok_or_else(missing)?;
158 Ok(())
159 }
160
161 fn place(&self, _: &str, _: [u8; 16], _: &str) -> Result<()> {
162 unimplemented!()
163 }
164
165 fn commit(&self, _: &str, _: &Transaction) -> Result<()> {
166 unimplemented!()
167 }
168}
169
170#[test]
171fn mapping_hides_the_folder_and_keeps_each_picture_once() {
172 let folder = Folder::default();
173 assert!(mappings(&folder).unwrap().is_empty());
174 let mapped = map(&folder, "Launch", 13, b"rocket", "png").unwrap();
175 assert_eq!(mapped.len(), 1);
176 assert_eq!(
177 *folder.hidden.lock().unwrap(),
178 BTreeSet::from([FOLDER.to_owned()])
179 );
180 let rocket = art_name(b"rocket", "png");
181 assert_eq!(art(&folder, &rocket).unwrap(), b"rocket");
182 // A second tag with the same picture, and a drawing.
183 map(&folder, "Ship it", 127, b"rocket", "png").unwrap();
184 let mapped = map(&folder, "Idea", 21, b"<svg/>", "svg").unwrap();
185 assert_eq!(
186 mapped
187 .iter()
188 .map(|mapping| (mapping.name.as_str(), mapping.shape))
189 .collect::<Vec<_>>(),
190 [("Launch", 13), ("Ship it", 127), ("Idea", 21)]
191 );
192 assert_eq!(
193 *folder.kept.lock().unwrap(),
194 [
195 format!("{ART}/{rocket}"),
196 format!("{ART}/{}", art_name(b"<svg/>", "svg"))
197 ]
198 );
199 // Only the pictures and the mapping are left: no written file stays behind.
200 let files: Vec<_> = folder.files.lock().unwrap().keys().cloned().collect();
201 assert_eq!(files.len(), 3);
202 assert!(files.iter().all(|file| !file.ends_with(".tmp")));
203 // The same tag mapped again takes the new picture.
204 let mapped = map(&folder, "Launch", 13, b"flame", "png").unwrap();
205 assert_eq!(mapped[0].art, art_name(b"flame", "png"));
206 assert_eq!(mappings(&folder).unwrap(), mapped);
207}
208
209/// Another writer read the mapping before this one replaced it and replaced it just after,
210/// without this one's tag: this one reads it back and merges again.
211#[test]
212fn a_writer_overtaken_merges_again() {
213 let folder = Folder::default();
214 map(&folder, "Theirs before", 13, b"one", "png").unwrap();
215 let theirs = vec![
216 mappings(&folder).unwrap()[0].clone(),
217 mapping("Theirs", 17, "two", crate::now()),
218 ];
219 *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap());
220 let mapped = map(&folder, "Ours", 21, b"three", "png").unwrap();
221 let names: BTreeSet<_> = mapped.iter().map(|mapping| mapping.name.as_str()).collect();
222 assert_eq!(names, BTreeSet::from(["Theirs before", "Theirs", "Ours"]));
223 assert!(folder.race.lock().unwrap().is_none());
224}
225
226#[test]
227fn unreadable_mappings_and_pictures_are_passed_over() {
228 let folder = Folder::default();
229 map(&folder, "Launch", 13, b"rocket", "png").unwrap();
230 let rocket = art_name(b"rocket", "png");
231 let entry = |art: &str| serde_json::json!({"name": "Odd", "shape": 1, "art": art, "mapped": 1});
232 let stored = serde_json::json!([
233 mappings(&folder).unwrap()[0],
234 entry("../../Open Notebook.onetoc2"),
235 entry(&rocket.replace(".png", ".exe")),
236 {"name": "No art"},
237 7,
238 ]);
239 folder
240 .files
241 .lock()
242 .unwrap()
243 .insert(MAPPING.into(), stored.to_string().into_bytes());
244 assert_eq!(mappings(&folder).unwrap().len(), 1);
245 folder
246 .files
247 .lock()
248 .unwrap()
249 .insert(MAPPING.into(), b"{not json".to_vec());
250 assert!(mappings(&folder).unwrap().is_empty());
251 let kind = |result: Result<Vec<u8>>| match result {
252 Err(Error::Io(error)) => error.kind(),
253 other => panic!("{other:?}"),
254 };
255 assert_eq!(
256 kind(art(&folder, "../tags.json")),
257 io::ErrorKind::InvalidInput
258 );
259 folder
260 .files
261 .lock()
262 .unwrap()
263 .insert(format!("{ART}/{rocket}"), b"rock".to_vec());
264 assert_eq!(kind(art(&folder, &rocket)), io::ErrorKind::InvalidData);
265 assert!(map(&folder, "Tag", 13, b"x", "gif").is_err());
266}
267
268/// On this computer the folder takes the attribute macOS keeps, and the notebook's catalog
269/// never lists it.
270#[test]
271fn a_notebook_folder_keeps_the_art_hidden() {
272 let temporary = tempfile::tempdir().unwrap();
273 let root = temporary.path().join("Notebook");
274 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
275 let mut notebook =
276 crate::session::Notebook::create(&root, temporary.path().join("cache"), 0x00f0_c090, &page)
277 .unwrap();
278 let mapped = notebook
279 .map_tag_art("Launch", 13, b"rocket", "png")
280 .unwrap();
281 assert_eq!(notebook.tag_art().unwrap(), mapped);
282 assert_eq!(notebook.tag_art_file(&mapped[0].art).unwrap(), b"rocket");
283 let folder = root.join(FOLDER);
284 assert!(folder.join("tags.json").is_file());
285 #[cfg(target_vendor = "apple")]
286 {
287 use nix::sys::stat::{FileFlag, stat};
288 let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags);
289 assert!(flags.contains(FileFlag::UF_HIDDEN));
290 }
291 notebook.refresh().unwrap();
292 assert!(notebook.catalog().groups.is_empty());
293 assert_eq!(notebook.catalog().sections.len(), 1);
294}
crates/notebook/src/smb/mod.rs+251-18
...@@ -30,6 +30,9 @@ mod remote;...@@ -30,6 +30,9 @@ mod remote;
30pub use directory::DirectoryEntry;30pub use directory::DirectoryEntry;
31pub use remote::SmbRemote;31pub use remote::SmbRemote;
3232
33/// FILE_ATTRIBUTE_HIDDEN.
34pub const HIDDEN: u32 = 0x2;
35
33#[derive(Default)]36#[derive(Default)]
34pub struct Credentials<'a> {37pub struct Credentials<'a> {
35 pub username: &'a str,38 pub username: &'a str,
...@@ -42,15 +45,17 @@ pub struct Credentials<'a> {...@@ -42,15 +45,17 @@ pub struct Credentials<'a> {
42/// Paths are relative to the share; both `/` and `\` are separators.45/// Paths are relative to the share; both `/` and `\` are separators.
43pub struct Client {46pub struct Client {
44 connection: Mutex<Option<Connection>>,47 connection: Mutex<Option<Connection>>,
48 /// The server as `connect` was given it, which names the share's `location`.
49 address: String,
45 tree: Tree,50 tree: Tree,
46 timeout: Duration,51 timeout: Duration,
47 runtime: Mutex<Option<Runtime>>,52 runtime: Mutex<Option<Runtime>>,
48}53}
4954
50impl Client {55impl Client {
51 /// The server and share, as `//server/share`.56 /// The notebook folder `root` on this share, as `crate::location` names it.
52 pub(crate) fn share(&self) -> String {57 pub(crate) fn location(&self, root: &str) -> String {
53 format!("//{}/{}", self.tree.server, self.tree.share_name)58 crate::location::smb(&self.address, &self.tree.share_name, root)
54 }59 }
5560
56 pub fn connect(61 pub fn connect(
...@@ -69,25 +74,18 @@ impl Client {...@@ -69,25 +74,18 @@ impl Client {
69 let (connection, tree) = runtime74 let (connection, tree) = runtime
70 .block_on(async {75 .block_on(async {
71 tokio::time::timeout(timeout, async {76 tokio::time::timeout(timeout, async {
72 let mut connection = Connection::connect(address, timeout).await?;77 let mut connection = sign_in(address, &credentials, timeout).await?;
73 connection.set_compression_requested(false);78 let tree = Tree::connect(&mut connection, share)
74 connection.negotiate().await?;79 .await
75 Session::setup(80 .map_err(io::Error::other)?;
76 &mut connection,81 Ok::<_, io::Error>((connection, tree))
77 credentials.username,
78 credentials.password,
79 credentials.domain,
80 )
81 .await?;
82 let tree = Tree::connect(&mut connection, share).await?;
83 Ok::<_, smb2::Error>((connection, tree))
84 })82 })
85 .await83 .await
86 })84 })
87 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))?85 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??;
88 .map_err(io::Error::other)?;
89 Ok(Self {86 Ok(Self {
90 connection: Mutex::new(Some(connection)),87 connection: Mutex::new(Some(connection)),
88 address: address.to_owned(),
91 tree,89 tree,
92 timeout,90 timeout,
93 runtime: Mutex::new(Some(runtime)),91 runtime: Mutex::new(Some(runtime)),
...@@ -211,6 +209,7 @@ impl Client {...@@ -211,6 +209,7 @@ impl Client {
211 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock,209 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock,
212 0xc0000011 => io::ErrorKind::UnexpectedEof,210 0xc0000011 => io::ErrorKind::UnexpectedEof,
213 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound,211 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound,
212 0xc0000035 => io::ErrorKind::AlreadyExists,
214 // Windows reports a delete-pending file as access denied too.213 // Windows reports a delete-pending file as access denied too.
215 0xc0000022 | 0xc0000056 => io::ErrorKind::PermissionDenied,214 0xc0000022 | 0xc0000056 => io::ErrorKind::PermissionDenied,
216 0xc0000103 => io::ErrorKind::NotADirectory,215 0xc0000103 => io::ErrorKind::NotADirectory,
...@@ -288,6 +287,15 @@ impl Client {...@@ -288,6 +287,15 @@ impl Client {
288 /// Renames or moves a file or directory within the share; an existing target is an287 /// Renames or moves a file or directory within the share; an existing target is an
289 /// error.288 /// error.
290 pub fn rename(&self, from: &str, to: &str) -> io::Result<()> {289 pub fn rename(&self, from: &str, to: &str) -> io::Result<()> {
290 self.rename_over(from, to, false)
291 }
292
293 /// Renames a file over another within the share, replacing it.
294 pub fn replace(&self, from: &str, to: &str) -> io::Result<()> {
295 self.rename_over(from, to, true)
296 }
297
298 fn rename_over(&self, from: &str, to: &str, replace: bool) -> io::Result<()> {
291 if to.is_empty() || to.contains('\0') {299 if to.is_empty() || to.contains('\0') {
292 return Err(io::ErrorKind::InvalidInput.into());300 return Err(io::ErrorKind::InvalidInput.into());
293 }301 }
...@@ -303,7 +311,9 @@ impl Client {...@@ -303,7 +311,9 @@ impl Client {
303 .encode_utf16()311 .encode_utf16()
304 .flat_map(u16::to_le_bytes)312 .flat_map(u16::to_le_bytes)
305 .collect();313 .collect();
314 // FileRenameInformation: ReplaceIfExists, then reserved bytes and no root directory.
306 let mut buffer = vec![0; 16];315 let mut buffer = vec![0; 16];
316 buffer[0] = u8::from(replace);
307 buffer.extend_from_slice(&u32::try_from(name.len()).unwrap().to_le_bytes());317 buffer.extend_from_slice(&u32::try_from(name.len()).unwrap().to_le_bytes());
308 buffer.extend_from_slice(&name);318 buffer.extend_from_slice(&name);
309 let _: SetInfoResponse = self.request(319 let _: SetInfoResponse = self.request(
...@@ -319,6 +329,47 @@ impl Client {...@@ -319,6 +329,47 @@ impl Client {
319 file.close()329 file.close()
320 }330 }
321331
332 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010
333 /// skips a hidden folder.
334 pub fn hide(&self, path: &str) -> io::Result<()> {
335 // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES.
336 let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?;
337 let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?;
338 // FileBasicInformation: four times, then the attributes; a time of 0 stays as it is.
339 let basic: QueryInfoResponse = self.request(
340 Command::QueryInfo,
341 QueryInfoRequest {
342 info_type: InfoType::File,
343 file_info_class: 4,
344 output_buffer_length: 40,
345 additional_information: 0,
346 flags: 0,
347 file_id,
348 input_buffer: Vec::new(),
349 },
350 )?;
351 let attributes = basic
352 .output_buffer
353 .get(32..36)
354 .and_then(|bytes| bytes.try_into().ok())
355 .map(u32::from_le_bytes)
356 .ok_or(io::ErrorKind::InvalidData)?;
357 // Set even where reported: Samba reports a dot name hidden without storing it so.
358 let mut buffer = vec![0; 40];
359 buffer[32..36].copy_from_slice(&(attributes | HIDDEN).to_le_bytes());
360 let _: SetInfoResponse = self.request(
361 Command::SetInfo,
362 SetInfoRequest {
363 info_type: InfoType::File,
364 file_info_class: 4,
365 additional_information: 0,
366 file_id,
367 buffer,
368 },
369 )?;
370 file.close()
371 }
372
322 /// Deletes a file or an empty directory.373 /// Deletes a file or an empty directory.
323 pub fn delete(&self, path: &str) -> io::Result<()> {374 pub fn delete(&self, path: &str) -> io::Result<()> {
324 self.open_with(path, 0x00010000, 7, CreateDisposition::FileOpen, 0x1000)?375 self.open_with(path, 0x00010000, 7, CreateDisposition::FileOpen, 0x1000)?
...@@ -363,7 +414,7 @@ impl Client {...@@ -363,7 +414,7 @@ impl Client {
363 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.414 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.
364 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {415 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
365 self.read_with(path, |file| {416 self.read_with(path, |file| {
366 onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit)417 snapshot(|offset, output| file.read_at(offset, output), limit).map(Some)
367 })418 })
368 }419 }
369420
...@@ -466,6 +517,158 @@ impl Client {...@@ -466,6 +517,158 @@ impl Client {
466 }517 }
467}518}
468519
520/// The disk shares the server at `address` offers the account, by name.
521pub fn shares(
522 address: &str,
523 credentials: Credentials<'_>,
524 timeout: Duration,
525) -> io::Result<Vec<String>> {
526 if Handle::try_current().is_ok() || timeout.is_zero() {
527 return Err(io::ErrorKind::InvalidInput.into());
528 }
529 let runtime = tokio::runtime::Builder::new_current_thread()
530 .enable_all()
531 .build()?;
532 let shares = runtime
533 .block_on(async {
534 tokio::time::timeout(timeout, async {
535 let mut connection = sign_in(address, &credentials, timeout).await?;
536 let shares = smb2::client::list_shares(&mut connection)
537 .await
538 .map_err(io::Error::other);
539 connection.mark_dead();
540 shares
541 })
542 .await
543 })
544 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??;
545 Ok(shares.into_iter().map(|share| share.name).collect())
546}
547
548/// A negotiated session with the server at `address`, signed in with `credentials`.
549async fn sign_in(
550 address: &str,
551 credentials: &Credentials<'_>,
552 timeout: Duration,
553) -> io::Result<Connection> {
554 let mut connection = Connection::connect(address, timeout)
555 .await
556 .map_err(io::Error::other)?;
557 connection.set_compression_requested(false);
558 if let Err(error) = connection.negotiate().await {
559 connection.mark_dead();
560 return Err(if speaks_only_smb1(address, timeout).await {
561 io::Error::new(io::ErrorKind::Unsupported, Refusal::Smb1)
562 } else {
563 io::Error::other(error)
564 });
565 }
566 Session::setup(
567 &mut connection,
568 credentials.username,
569 credentials.password,
570 credentials.domain,
571 )
572 .await
573 .map_err(io::Error::other)?;
574 Ok(connection)
575}
576
577/// Whether the server at `address` agrees to SMB1, as one that turned SMB2 away does when
578/// it speaks only SMB1.
579async fn speaks_only_smb1(address: &str, timeout: Duration) -> bool {
580 use tokio::io::{AsyncReadExt, AsyncWriteExt};
581 const DIALECT: &[u8] = b"\x02NT LM 0.12\0";
582 let mut message = Vec::with_capacity(35 + DIALECT.len());
583 message.extend_from_slice(b"\xffSMB\x72");
584 // Status, then flags (canonical paths, case-insensitive) and flags2 (NT status codes).
585 message.extend_from_slice(&[0, 0, 0, 0, 0x18, 0x01, 0x40]);
586 // PID high, signature, reserved, TID, PID, UID and MID.
587 message.extend_from_slice(&[0; 14]);
588 message.extend_from_slice(&[0xff, 0xff, 0xff, 0xfe, 0, 0, 0, 0]);
589 message.push(0);
590 message.extend_from_slice(&(DIALECT.len() as u16).to_le_bytes());
591 message.extend_from_slice(DIALECT);
592 let probe = async {
593 let mut stream = tokio::net::TcpStream::connect(address).await?;
594 stream
595 .write_all(&(message.len() as u32).to_be_bytes())
596 .await?;
597 stream.write_all(&message).await?;
598 // The frame's length, the SMB1 header, then the word count and the dialect chosen.
599 let mut reply = [0; 39];
600 stream.read_exact(&mut reply).await?;
601 io::Result::Ok(reply[4..8] == *b"\xffSMB" && reply[37..] == [0, 0])
602 };
603 matches!(tokio::time::timeout(timeout, probe).await, Ok(Ok(true)))
604}
605
606/// Why a server turned a connection or a listing away, as a person can remedy it.
607#[derive(Clone, Copy, Debug, PartialEq, Eq)]
608pub enum Refusal {
609 /// Nothing answered at the address in time.
610 Unreachable,
611 /// The server speaks only SMB1, which this client does not.
612 Smb1,
613 /// The server refused the name and password, or a guest.
614 SignIn,
615 /// The server has no share by that name.
616 NoShare,
617 /// The account may not open the share or folder.
618 Denied,
619 /// The folder is not on the share.
620 NoFolder,
621 Other,
622}
623
624impl std::fmt::Display for Refusal {
625 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626 f.write_str(match self {
627 Self::Unreachable => "The server can't be reached",
628 Self::Smb1 => "The server speaks only SMB1",
629 Self::SignIn => "The server refused the sign-in",
630 Self::NoShare => "The server has no such share",
631 Self::Denied => "Access denied",
632 Self::NoFolder => "The folder is not on the share",
633 Self::Other => "The server refused the request",
634 })
635 }
636}
637
638impl std::error::Error for Refusal {}
639
640impl Refusal {
641 /// What an error from `Client::connect`, `shares` or `Client::read_dir` means.
642 pub fn of(error: &io::Error) -> Self {
643 use smb2::ErrorKind as Smb;
644 let inner = error.get_ref();
645 if let Some(refusal) = inner.and_then(|inner| inner.downcast_ref::<Self>()) {
646 return *refusal;
647 }
648 let Some(smb) = inner.and_then(|inner| inner.downcast_ref::<smb2::Error>()) else {
649 return match error.kind() {
650 io::ErrorKind::TimedOut | io::ErrorKind::NotConnected => Self::Unreachable,
651 _ => Self::Other,
652 };
653 };
654 let tree = matches!(
655 smb,
656 smb2::Error::Protocol {
657 command: Command::TreeConnect,
658 ..
659 }
660 );
661 match smb.kind() {
662 Smb::AuthRequired | Smb::SigningRequired => Self::SignIn,
663 Smb::NotFound if tree => Self::NoShare,
664 Smb::NotFound | Smb::NotADirectory => Self::NoFolder,
665 Smb::AccessDenied => Self::Denied,
666 Smb::Io | Smb::ConnectionLost | Smb::TimedOut => Self::Unreachable,
667 _ => Self::Other,
668 }
669 }
670}
671
469impl Drop for Client {672impl Drop for Client {
470 fn drop(&mut self) {673 fn drop(&mut self) {
471 self.retire();674 self.retire();
...@@ -817,6 +1020,36 @@ impl CommitIo for File<'_> {...@@ -817,6 +1020,36 @@ impl CommitIo for File<'_> {
817}1020}
8181021
819/// The reads `Stamp::check` makes: the header and a probe of the last byte.1022/// The reads `Stamp::check` makes: the header and a probe of the last byte.
1023/// `onestore::read_snapshot`, telling a file that is stably unreadable from a torn read:
1024/// storage that is consistent yet fails validation is refused, not reported as contention.
1025fn snapshot(
1026 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
1027 limit: usize,
1028) -> io::Result<Vec<u8>> {
1029 if let Some(bytes) = onestore::read_snapshot(&mut read, limit)? {
1030 return Ok(bytes);
1031 }
1032 let storage =
1033 onestore::read_storage_snapshot(&mut read, limit)?.ok_or(io::ErrorKind::WouldBlock)?;
1034 // A writer may have finished between the two reads.
1035 let at = |offset: u64, output: &mut [u8]| {
1036 let rest = storage.get(offset as usize..).unwrap_or_default();
1037 let count = rest.len().min(output.len());
1038 output[..count].copy_from_slice(&rest[..count]);
1039 Ok(count)
1040 };
1041 if let Some(bytes) = onestore::read_snapshot(at, limit)? {
1042 return Ok(bytes);
1043 }
1044 let locked =
1045 onestore::Store::parse(&storage).is_ok_and(|store| crate::discover::locked(&store));
1046 Err(if locked {
1047 io::Error::new(io::ErrorKind::Unsupported, "Password protected")
1048 } else {
1049 io::Error::new(io::ErrorKind::InvalidData, "Can't read this section")
1050 })
1051}
1052
820fn checked(stamp: &onestore::Stamp) -> [(u64, usize); 2] {1053fn checked(stamp: &onestore::Stamp) -> [(u64, usize); 2] {
821 [(0, 1024), (stamp.length.saturating_sub(1), 2)]1054 [(0, 1024), (stamp.length.saturating_sub(1), 2)]
822}1055}
crates/notebook/src/smb/tests.rs+209
...@@ -623,6 +623,68 @@ fn read_limits_respect_negotiation_and_available_credits() {...@@ -623,6 +623,68 @@ fn read_limits_respect_negotiation_and_available_credits() {
623623
624/// Notebook structure over the share: sections and groups created, renamed, coloured,624/// Notebook structure over the share: sections and groups created, renamed, coloured,
625/// ordered and deleted through `Notebook::open_smb`, seen again by a fresh discovery.625/// ordered and deleted through `Notebook::open_smb`, seen again by a fresh discovery.
626/// The hidden attribute lands through the client, on a folder whose name Samba would not
627/// hide by itself too, and a notebook's art folder takes it; a replacing rename replaces.
628#[test]
629#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
630fn live_sidecar() {
631 let client = std::sync::Arc::new(client());
632 let root = format!("sidecar-{}", std::process::id());
633 client.create_directory(&root).unwrap();
634 let attributes = |name: &str| {
635 client
636 .read_dir(&root, 100)
637 .unwrap()
638 .into_iter()
639 .find(|entry| entry.name == name)
640 .unwrap()
641 .attributes
642 };
643 let plain = format!("{root}/plain");
644 client.create_directory(&plain).unwrap();
645 assert_eq!(attributes("plain") & HIDDEN, 0);
646 client.hide(&plain).unwrap();
647 client.hide(&plain).unwrap();
648 assert_eq!(attributes("plain") & (HIDDEN | 0x10), HIDDEN | 0x10);
649 let cache = tempfile::tempdir().unwrap();
650 let notebook =
651 crate::session::Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path())
652 .unwrap();
653 let mapped = notebook
654 .map_tag_art("Launch", 13, b"rocket", "png")
655 .unwrap();
656 assert_eq!(attributes(".snowbound") & HIDDEN, HIDDEN);
657 assert_eq!(notebook.tag_art().unwrap(), mapped);
658 assert_eq!(notebook.tag_art_file(&mapped[0].art).unwrap(), b"rocket");
659 let [first, second] = ["first", "second"].map(|name| format!("{root}/{name}"));
660 client.create(&first, b"first").unwrap();
661 client.create(&second, b"second").unwrap();
662 assert_eq!(
663 client.rename(&first, &second).unwrap_err().kind(),
664 io::ErrorKind::AlreadyExists
665 );
666 client.replace(&first, &second).unwrap();
667 assert_eq!(client.read_asset(&second, 100).unwrap(), b"first");
668 if std::env::var_os("ONESTORE_SMB_KEEP").is_none() {
669 client.delete(&second).unwrap();
670 client.delete(&plain).unwrap();
671 for file in client
672 .read_dir(&format!("{root}/.snowbound/tags"), 100)
673 .unwrap()
674 {
675 if file.attributes & 0x10 == 0 {
676 client
677 .delete(&format!("{root}/.snowbound/tags/{}", file.name))
678 .unwrap();
679 }
680 }
681 for path in [".snowbound/tags", ".snowbound/tags.json", ".snowbound"] {
682 client.delete(&format!("{root}/{path}")).unwrap();
683 }
684 client.delete(&root).unwrap();
685 }
686}
687
626#[test]688#[test]
627#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]689#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
628fn live_structure() {690fn live_structure() {
...@@ -729,3 +791,150 @@ fn live_structure() {...@@ -729,3 +791,150 @@ fn live_structure() {
729 }791 }
730 client.delete(&root).unwrap();792 client.delete(&root).unwrap();
731}793}
794
795/// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a
796/// guest, to a server with a share `agent`; only lists and reads.
797#[test]
798#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba with a share `agent`"]
799fn live_refusals_name_their_remedy() {
800 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
801 let user = std::env::var("ONESTORE_SMB_LAB_USER").unwrap_or_default();
802 let password = std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default();
803 let timeout = Duration::from_secs(10);
804 let account = || Credentials {
805 username: &user,
806 password: &password,
807 domain: "",
808 };
809 assert!(
810 shares(&address, account(), timeout)
811 .unwrap()
812 .contains(&"agent".to_owned())
813 );
814 let refusal = |result: io::Result<Client>| Refusal::of(&result.err().unwrap());
815 assert_eq!(
816 refusal(Client::connect(&address, "missing", account(), timeout)),
817 Refusal::NoShare
818 );
819 assert_eq!(
820 refusal(Client::connect("127.0.0.1:9", "agent", account(), timeout)),
821 Refusal::Unreachable
822 );
823 let client = Client::connect(&address, "agent", account(), timeout).unwrap();
824 assert_eq!(
825 Refusal::of(&client.read_dir("missing-folder", 10).unwrap_err()),
826 Refusal::NoFolder
827 );
828 if !user.is_empty() {
829 let wrong = Credentials {
830 password: "not the password",
831 ..account()
832 };
833 assert_eq!(
834 refusal(Client::connect(&address, "agent", wrong, timeout)),
835 Refusal::SignIn
836 );
837 }
838}
839
840/// A server that answers only SMB1: it hangs up on SMB2's negotiation and agrees to NT LM
841/// 0.12, as Windows XP or Samba held to NT1 do.
842#[test]
843fn an_smb1_only_server_is_named() {
844 use std::io::{Read, Write};
845 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
846 let address = listener.local_addr().unwrap().to_string();
847 let server = std::thread::spawn(move || {
848 for _ in 0..2 {
849 let (mut stream, _) = listener.accept().unwrap();
850 let mut length = [0; 4];
851 stream.read_exact(&mut length).unwrap();
852 let mut message = vec![0; u32::from_be_bytes(length) as usize];
853 stream.read_exact(&mut message).unwrap();
854 if message.starts_with(b"\xffSMB") {
855 let mut reply = b"\xffSMB\x72".to_vec();
856 reply.resize(32, 0);
857 reply.extend_from_slice(&[17, 0, 0]);
858 reply.resize(32 + 1 + 34 + 2, 0);
859 stream
860 .write_all(&(reply.len() as u32).to_be_bytes())
861 .unwrap();
862 stream.write_all(&reply).unwrap();
863 }
864 }
865 });
866 let error = Client::connect(
867 &address,
868 "share",
869 Credentials::default(),
870 Duration::from_secs(5),
871 )
872 .err()
873 .unwrap();
874 server.join().unwrap();
875 assert_eq!(Refusal::of(&error), Refusal::Smb1);
876}
877
878#[test]
879#[ignore = "requires ONESTORE_SMB1_LAB pointing to a disposable Samba held to SMB1 (NT1)"]
880fn live_smb1_only_server_is_named() {
881 let error = Client::connect(
882 &std::env::var("ONESTORE_SMB1_LAB").unwrap(),
883 "agent",
884 Credentials::default(),
885 Duration::from_secs(10),
886 )
887 .err()
888 .unwrap();
889 assert_eq!(Refusal::of(&error), Refusal::Smb1, "{error}");
890}
891
892fn corpus(path: &str) -> Vec<u8> {
893 fs::read(format!(
894 "{}/../../corpus/{path}",
895 env!("CARGO_MANIFEST_DIR")
896 ))
897 .unwrap()
898}
899
900fn from(bytes: &[u8]) -> impl FnMut(u64, &mut [u8]) -> io::Result<usize> + '_ {
901 |offset, output| {
902 let rest = bytes.get(offset as usize..).unwrap_or_default();
903 let count = rest.len().min(output.len());
904 output[..count].copy_from_slice(&rest[..count]);
905 Ok(count)
906 }
907}
908
909#[test]
910fn a_password_protected_section_is_not_contention() {
911 let bytes = corpus("native-encrypted/encrypted-01/notebook/synthetic.one");
912 let error = snapshot(from(&bytes), 1 << 20).unwrap_err();
913 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
914 let valid = corpus("outline-edit/before/notebook/synthetic.one");
915 assert_eq!(snapshot(from(&valid), 1 << 20).unwrap(), valid);
916}
917
918#[test]
919fn a_stably_invalid_file_is_not_contention() {
920 // OneNote's stub: consistent storage whose object space has no revisions yet.
921 let bytes = corpus("native-encrypted/cold-encrypted-02/notebook/Open Notebook.one");
922 let error = snapshot(from(&bytes), 1 << 20).unwrap_err();
923 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
924}
925
926#[test]
927fn a_torn_read_is_contention() {
928 let bytes = corpus("outline-edit/before/notebook/synthetic.one");
929 let mut headers = 0;
930 let torn = |offset: u64, output: &mut [u8]| {
931 let count = from(&bytes)(offset, output)?;
932 if offset == 0 {
933 headers += 1;
934 output[1023] ^= headers as u8;
935 }
936 Ok(count)
937 };
938 let error = snapshot(torn, 1 << 20).unwrap_err();
939 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
940}
crates/notebook/src/sync.rs+43-2
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1use super::*;1use super::*;
2use crate::working::{Request, Sealed};2use crate::{
3 resolve::{Merged, Version},
4 working::{Request, Sealed},
5};
3use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction};6use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction};
4use rusqlite::OptionalExtension;7use rusqlite::OptionalExtension;
5use std::{8use std::{
...@@ -18,6 +21,25 @@ pub trait Remote {...@@ -18,6 +21,25 @@ pub trait Remote {
18 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError>;21 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError>;
19 /// Confirms that the file still has `base`'s stamp and is durable (`onestore::confirm`).22 /// Confirms that the file still has `base`'s stamp and is durable (`onestore::confirm`).
20 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError>;23 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError>;
24 /// The versions a file provider keeps beside the file, as iCloud Drive keeps the commits
25 /// that lost to another device's (unresolved conflict versions); none by default.
26 /// Synchronization merges each into the file, then retires it.
27 fn versions(&mut self) -> io::Result<Vec<Version>> {
28 Ok(Vec::new())
29 }
30 /// A version's image.
31 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
32 Err(io::Error::new(
33 io::ErrorKind::NotFound,
34 format!("No version {id}"),
35 ))
36 }
37 /// Retires a version the file now holds everything of; with `keep`, one that is another
38 /// section or cannot be read, kept first as a file of its own beside this one.
39 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
40 let _ = (id, keep);
41 Ok(())
42 }
21}43}
2244
23#[derive(Debug, Clone, PartialEq, Eq)]45#[derive(Debug, Clone, PartialEq, Eq)]
...@@ -156,8 +178,26 @@ impl Replica {...@@ -156,8 +178,26 @@ impl Replica {
156 /// changed. Reads the remote image only when its stamp moved; network I/O holds178 /// changed. Reads the remote image only when its stamp moved; network I/O holds
157 /// synchronization ownership without holding the cache mutex. Uncertain attempts are179 /// synchronization ownership without holding the cache mutex. Uncertain attempts are
158 /// never replayed.180 /// never replayed.
181 /// Versions the remote keeps beside the file merge into it first, each published as one
182 /// more revision and then retired (`resolve.rs`).
159 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> {183 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> {
160 let _owner = self.sync_owner()?;184 let _owner = self.sync_owner()?;
185 for version in remote.versions().map_err(Error::RemoteIo)? {
186 let image = remote.version(&version.id).map_err(Error::RemoteIo)?;
187 let current = remote.read().map_err(Error::RemoteIo)?;
188 let device = version.device.as_deref().unwrap_or("Another device");
189 let keep = match crate::resolve::merge(&current, &image, device) {
190 Ok(Merged::Held) => false,
191 Ok(Merged::Publish(transaction)) => {
192 remote.publish(&transaction)?;
193 false
194 }
195 // A version this cannot merge is kept whole rather than lost.
196 Ok(Merged::Foreign) | Err(Error::Document(_) | Error::Rejected(_)) => true,
197 Err(error) => return Err(error),
198 };
199 remote.retire(&version.id, keep).map_err(Error::RemoteIo)?;
200 }
161 let state = state(&*self.lock()?)?;201 let state = state(&*self.lock()?)?;
162 let observed = remote.stamp().map_err(Error::RemoteIo)?;202 let observed = remote.stamp().map_err(Error::RemoteIo)?;
163 if let Some(blocked) = &state.blocked203 if let Some(blocked) = &state.blocked
...@@ -482,7 +522,8 @@ impl Replica {...@@ -482,7 +522,8 @@ impl Replica {
482 None if !state.queued => state.base,522 None if !state.queued => state.base,
483 None => return Ok(false),523 None => return Ok(false),
484 };524 };
485 Ok(remote.stamp().map_err(Error::RemoteIo)? == expected)525 Ok(remote.stamp().map_err(Error::RemoteIo)? == expected
526 && remote.versions().map_err(Error::RemoteIo)?.is_empty())
486 }527 }
487}528}
488529
crates/notebook/src/worker.rs+56-1
...@@ -3,7 +3,7 @@ use std::{...@@ -3,7 +3,7 @@ use std::{
3 collections::hash_map::RandomState,3 collections::hash_map::RandomState,
4 hash::BuildHasher,4 hash::BuildHasher,
5 sync::{5 sync::{
6 Arc,6 Arc, Mutex,
7 atomic::{AtomicBool, AtomicU64, Ordering},7 atomic::{AtomicBool, AtomicU64, Ordering},
8 mpsc::{self, SyncSender},8 mpsc::{self, SyncSender},
9 },9 },
...@@ -21,9 +21,21 @@ pub(super) struct Signal {...@@ -21,9 +21,21 @@ pub(super) struct Signal {
21 /// A watch on the file's folder wakes the worker when the file changes, so an idle worker21 /// 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`).22 /// waits for that instead of checking the file on its own (`Background::hold`).
23 pub(crate) watched: AtomicBool,23 pub(crate) watched: AtomicBool,
24 pause: Mutex<Pause>,
24 sender: SyncSender<()>,25 sender: SyncSender<()>,
25}26}
2627
28/// How local edits wait before they publish (`SyncWorker::set_pause`).
29#[derive(Default)]
30struct Pause {
31 length: Duration,
32 /// When the first and the last edit waiting arrived.
33 waiting: Option<(Instant, Instant)>,
34}
35
36/// The longest local edits wait for a pause in typing.
37const LONGEST: Duration = Duration::from_secs(30);
38
27impl Signal {39impl Signal {
28 pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) {40 pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) {
29 let (sender, receiver) = mpsc::sync_channel(1);41 let (sender, receiver) = mpsc::sync_channel(1);
...@@ -33,6 +45,7 @@ impl Signal {...@@ -33,6 +45,7 @@ impl Signal {
33 synced: AtomicU64::new(0),45 synced: AtomicU64::new(0),
34 requested: AtomicBool::new(false),46 requested: AtomicBool::new(false),
35 watched: AtomicBool::new(false),47 watched: AtomicBool::new(false),
48 pause: Mutex::default(),
36 sender,49 sender,
37 };50 };
38 (Arc::new(signal), receiver)51 (Arc::new(signal), receiver)
...@@ -42,6 +55,28 @@ impl Signal {...@@ -42,6 +55,28 @@ impl Signal {
42 // One retained notification covers edits that arrive during network I/O.55 // One retained notification covers edits that arrive during network I/O.
43 let _ = self.sender.try_send(());56 let _ = self.sender.try_send(());
44 }57 }
58
59 /// A burst of local edits is durable.
60 pub(super) fn edited(&self) {
61 if let Ok(mut pause) = self.pause.lock() {
62 let now = Instant::now();
63 pause.waiting = Some((pause.waiting.map_or(now, |(first, _)| first), now));
64 }
65 self.wake();
66 }
67
68 /// How much longer waiting edits wait for a pause in typing, if they do.
69 fn paused(&self) -> Option<Duration> {
70 let pause = self.pause.lock().ok()?;
71 let (first, last) = pause.waiting?;
72 let now = Instant::now();
73 Some(
74 (last + pause.length)
75 .min(first + LONGEST)
76 .checked_duration_since(now)?,
77 )
78 .filter(|wait| !wait.is_zero())
79 }
45}80}
4681
47/// Owns automatic reconciliation. Dropping requests cancellation without blocking.82/// Owns automatic reconciliation. Dropping requests cancellation without blocking.
...@@ -65,6 +100,16 @@ impl SyncWorker {...@@ -65,6 +100,16 @@ impl SyncWorker {
65 Some(self.signal.synced.load(Ordering::Acquire)).filter(|time| *time != 0)100 Some(self.signal.synced.load(Ordering::Acquire)).filter(|time| *time != 0)
66 }101 }
67102
103 /// Local edits publish once `pause` passes without another, or once they have waited
104 /// half a minute, rather than each burst at once: a cloud drive uploads every publication
105 /// and turns each concurrent one into a conflict version. `wake` publishes them at once.
106 pub fn set_pause(&self, pause: Duration) {
107 if let Ok(mut current) = self.signal.pause.lock() {
108 current.length = pause;
109 }
110 self.signal.wake();
111 }
112
68 /// Working offline, the worker neither connects nor steps: local edits stay queued113 /// Working offline, the worker neither connects nor steps: local edits stay queued
69 /// until `wake`, or until working online again.114 /// until `wake`, or until working online again.
70 pub fn set_offline(&self, offline: bool) {115 pub fn set_offline(&self, offline: bool) {
...@@ -157,6 +202,16 @@ impl Replica {...@@ -157,6 +202,16 @@ impl Replica {
157 let _ = receiver.recv();202 let _ = receiver.recv();
158 continue;203 continue;
159 }204 }
205 if !worker_signal.requested.load(Ordering::Acquire)
206 && let Some(wait) = worker_signal.paused()
207 {
208 let _ = receiver.recv_timeout(wait);
209 continue;
210 }
211 if let Ok(mut pause) = worker_signal.pause.lock() {
212 // The step publishes what waits; edits from now on wait anew.
213 pause.waiting = None;
214 }
160 let result = match remote.as_mut() {215 let result = match remote.as_mut() {
161 Some(remote) => {216 Some(remote) => {
162 if reported && replica.settled(remote).unwrap_or(false) {217 if reported && replica.settled(remote).unwrap_or(false) {
crates/notebook/src/working.rs+2-1
...@@ -543,7 +543,7 @@ impl<'a> Working<'a> {...@@ -543,7 +543,7 @@ impl<'a> Working<'a> {
543 for (edit, id) in accepted.drain(..).zip(ids) {543 for (edit, id) in accepted.drain(..).zip(ids) {
544 (edit.reply)(Ok(id));544 (edit.reply)(Ok(id));
545 }545 }
546 crate::wake(worker);546 crate::edited(worker);
547 }547 }
548 Err(error) => {548 Err(error) => {
549 let message = error.to_string();549 let message = error.to_string();
...@@ -679,6 +679,7 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<...@@ -679,6 +679,7 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<
679 author,679 author,
680 objects,680 objects,
681 crate::now(),681 crate::now(),
682 None,
682 )?;683 )?;
683 added.push((author.clone(), edit));684 added.push((author.clone(), edit));
684 }685 }
crates/notebook/tests/cloud.rs created+874
...@@ -0,0 +1,874 @@
1//! Devices sharing a section through a cloud drive that, like iCloud Drive, syncs whole files
2//! with no lock and no compare-and-swap: an upload made from a copy the server has moved past
3//! makes one of the two the file and keeps the other as a conflict version, which every device
4//! sees. Each device commits to its own copy through its replica and merges the conflict
5//! versions it sees (`Remote::versions`). Random schedules of typing, new paragraphs, new,
6//! deleted and moved pages, offline spans and late deliveries must end with every device on
7//! the same file, holding each typed string once.
8
9#[path = "../../onestore/tests/support/sweep.rs"]
10mod sweep;
11
12use notebook::{Remote, Replica, Version};
13use onestore::{
14 CommitError, CommitState, ExGuid, PageCreation, PageEdit, Section, Stamp, Transaction,
15 document::{Format, Layout},
16 op::{Edit, Op, PageOp, SectionOp, lower_page},
17 page::{
18 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
19 text::new_id,
20 },
21};
22use std::{collections::BTreeSet, io};
23
24/// The file as the server holds it, and the versions it keeps beside it.
25struct Server {
26 current: Vec<u8>,
27 /// Names what `current` is, so a device knows whether its copy is.
28 id: u64,
29 next: u64,
30 conflicts: Vec<(u64, Vec<u8>, String)>,
31 /// Versions a device kept beside the file as files of their own.
32 kept: usize,
33}
34
35impl Server {
36 fn next(&mut self) -> u64 {
37 self.next += 1;
38 self.next
39 }
40}
41
42/// A device's copy of the file and what it has heard of the server.
43struct Copy {
44 bytes: Vec<u8>,
45 /// The server's file this copy was, before any local commit.
46 base: u64,
47 dirty: bool,
48 /// The conflict versions delivered to this device.
49 versions: Vec<(u64, Vec<u8>, String)>,
50 online: bool,
51}
52
53struct Device {
54 name: String,
55 copy: Copy,
56 replica: Replica,
57}
58
59/// A device's copy of the file as its replica publishes to it.
60struct Local<'a> {
61 copy: &'a mut Copy,
62 server: &'a mut Server,
63}
64
65fn refused() -> CommitError {
66 CommitError {
67 state: CommitState::NotCommitted,
68 error: io::Error::from(io::ErrorKind::WouldBlock),
69 }
70}
71
72impl Remote for Local<'_> {
73 fn read(&mut self) -> io::Result<Vec<u8>> {
74 Ok(self.copy.bytes.clone())
75 }
76
77 fn stamp(&mut self) -> io::Result<Stamp> {
78 Stamp::of(&self.copy.bytes).map_err(io::Error::other)
79 }
80
81 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
82 transaction
83 .apply(&mut self.copy.bytes)
84 .map_err(|_| refused())?;
85 self.copy.dirty = true;
86 Ok(())
87 }
88
89 fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> {
90 match Stamp::of(&self.copy.bytes) {
91 Ok(stamp) if stamp == *base => Ok(()),
92 _ => Err(refused()),
93 }
94 }
95
96 fn versions(&mut self) -> io::Result<Vec<Version>> {
97 Ok(self
98 .copy
99 .versions
100 .iter()
101 .map(|(id, _, device)| Version {
102 id: id.to_string(),
103 device: Some(device.clone()),
104 })
105 .collect())
106 }
107
108 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
109 self.copy
110 .versions
111 .iter()
112 .find(|(held, ..)| held.to_string() == id)
113 .map(|(_, bytes, _)| bytes.clone())
114 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
115 }
116
117 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
118 self.copy
119 .versions
120 .retain(|(held, ..)| held.to_string() != id);
121 let before = self.server.conflicts.len();
122 self.server
123 .conflicts
124 .retain(|(held, ..)| held.to_string() != id);
125 if keep && before != self.server.conflicts.len() {
126 self.server.kept += 1;
127 }
128 Ok(())
129 }
130}
131
132/// A small xorshift generator; schedules replay from their seed.
133struct Random(u64);
134
135impl Random {
136 fn next(&mut self) -> u64 {
137 self.0 ^= self.0 << 13;
138 self.0 ^= self.0 >> 7;
139 self.0 ^= self.0 << 17;
140 self.0
141 }
142
143 fn below(&mut self, n: usize) -> usize {
144 (self.next() % n.max(1) as u64) as usize
145 }
146}
147
148fn format() -> Format {
149 Format {
150 font: Some("Calibri".to_owned()),
151 font_size: Some(11.0),
152 language: Some(0x409),
153 ..Default::default()
154 }
155}
156
157fn paragraph(text: &str) -> PageParagraph {
158 PageParagraph {
159 id: new_id().unwrap(),
160 parent: None,
161 level: 1,
162 style: None,
163 format: Default::default(),
164 content: ParagraphContent::Text(TextObject {
165 id: new_id().unwrap(),
166 date_field: None,
167 text: Paragraph::new(text.into(), format()),
168 tags: Vec::new(),
169 }),
170 lists: Vec::new(),
171 tags: Vec::new(),
172 media: Default::default(),
173 collapsed: false,
174 }
175}
176
177/// `page` with a body outline holding one paragraph of `text`.
178fn with_body(page: &Page, text: &str) -> Page {
179 let mut page = page.clone();
180 let outline = Outline {
181 id: new_id().unwrap(),
182 title: false,
183 min_width: None,
184 layout: Layout {
185 x: Some(36.0),
186 y: Some(86.0),
187 ..Default::default()
188 },
189 indents: Vec::new(),
190 paragraphs: vec![paragraph(text)],
191 unsupported: Vec::new(),
192 };
193 page.objects.push(PageObject::Outline(outline));
194 page
195}
196
197/// A section of three pages, each with a body paragraph.
198fn fixture() -> Vec<u8> {
199 let source = onestore::create_empty_section("cloud.one", None).unwrap();
200 let arena = onestore::Arena::default();
201 let mut section = Section::open(&arena, source).unwrap();
202 for n in 0..3 {
203 let creation = PageCreation::new(None, Some(&format!("Page {n}")), "Fixture").unwrap();
204 let space = creation.space();
205 edit(
206 &mut section,
207 "Fixture",
208 vec![Op::Section(SectionOp::Create(creation))],
209 );
210 let page = section.page(space).unwrap();
211 let body = with_body(&page, &format!("Body {n} with words"));
212 let ops = lower_page(&page, &body)
213 .unwrap()
214 .into_iter()
215 .map(|op| Op::Page { space, op })
216 .collect();
217 edit(&mut section, "Fixture", ops);
218 }
219 section.seal().unwrap();
220 section.image()
221}
222
223fn edit(section: &mut Section<'_>, author: &str, ops: Vec<Op>) {
224 section
225 .apply(
226 author,
227 &Edit {
228 at: 133_000_000_000_000_000,
229 ops,
230 },
231 )
232 .unwrap();
233}
234
235/// Every body text of `page`: its identity and characters.
236fn bodies(page: &Page) -> Vec<(ExGuid, String)> {
237 page.objects
238 .iter()
239 .filter_map(|object| match object {
240 PageObject::Outline(outline) => Some(outline),
241 _ => None,
242 })
243 .flat_map(|outline| &outline.paragraphs)
244 .filter_map(|paragraph| match &paragraph.content {
245 ParagraphContent::Text(text) => Some((text.id, text.text.text().to_owned())),
246 _ => None,
247 })
248 .collect()
249}
250
251/// What a schedule typed, and where.
252#[derive(Default)]
253struct Typed {
254 /// Each typed string with the page it went into.
255 strings: Vec<(String, ExGuid)>,
256 /// Pages some device deleted, whose strings may be gone.
257 deleted: BTreeSet<ExGuid>,
258}
259
260impl Device {
261 fn remote<'a>(&'a mut self, server: &'a mut Server) -> (Local<'a>, &'a Replica) {
262 (
263 Local {
264 copy: &mut self.copy,
265 server,
266 },
267 &self.replica,
268 )
269 }
270
271 /// Commits what the replica holds and merges what it sees, as the sync thread does.
272 fn sync(&mut self, server: &mut Server) {
273 let (mut remote, replica) = self.remote(server);
274 for _ in 0..8 {
275 match replica.sync_once(&mut remote) {
276 Ok(synced)
277 if matches!(
278 synced.edit,
279 Some((_, notebook::EditStatus::Published { .. }))
280 ) => {}
281 Ok(_) => return,
282 Err(notebook::Error::Remote(error)) if error.state == CommitState::NotCommitted => {
283 }
284 Err(error) => panic!("{}: {error}", self.name),
285 }
286 }
287 }
288
289 /// Sends a changed copy to the server; one made from a file the server has moved past
290 /// becomes the file or a conflict version, either way round.
291 fn upload(&mut self, server: &mut Server, random: &mut Random) {
292 if !self.copy.online || !self.copy.dirty {
293 return;
294 }
295 self.copy.dirty = false;
296 if self.copy.base == server.id {
297 server.current = self.copy.bytes.clone();
298 server.id = server.next();
299 self.copy.base = server.id;
300 return;
301 }
302 let id = server.next();
303 if random.below(2) == 0 {
304 // This copy becomes the file; the server's goes beside it.
305 let lost = std::mem::replace(&mut server.current, self.copy.bytes.clone());
306 server.conflicts.push((id, lost, "The server".into()));
307 server.id = server.next();
308 self.copy.base = server.id;
309 } else {
310 server
311 .conflicts
312 .push((id, self.copy.bytes.clone(), self.name.clone()));
313 self.copy.bytes = server.current.clone();
314 self.copy.base = server.id;
315 }
316 }
317
318 /// Takes the server's file, or conflicts with it while this copy has changes.
319 fn download(&mut self, server: &mut Server, random: &mut Random) {
320 if !self.copy.online || self.copy.base == server.id {
321 return;
322 }
323 if self.copy.dirty {
324 return self.upload(server, random);
325 }
326 self.copy.bytes = server.current.clone();
327 self.copy.base = server.id;
328 }
329
330 /// Learns which conflict versions the server keeps.
331 fn deliver(&mut self, server: &Server) {
332 if self.copy.online {
333 self.copy.versions = server.conflicts.clone();
334 }
335 }
336
337 /// Makes a random edit, as someone using the device would.
338 fn edit(&mut self, random: &mut Random, typed: &mut Typed, serial: &mut u32) {
339 let pages = self.replica.pages().unwrap();
340 let (space, ..) = pages[random.below(pages.len())].clone();
341 let page = self.replica.page(space).unwrap();
342 *serial += 1;
343 let token = format!("⟨{}-{serial}⟩", self.name);
344 let at = crate_now();
345 let ops = match random.below(20) {
346 0..=12 => {
347 let texts = bodies(&page);
348 if texts.is_empty() {
349 return;
350 }
351 let (text, characters) = &texts[random.below(texts.len())];
352 // Anywhere but inside another typed string, so each stays whole to count.
353 let mut inside = false;
354 let mut boundaries = Vec::new();
355 for (at, character) in characters.char_indices() {
356 if !inside {
357 boundaries.push(at);
358 }
359 inside = (inside || character == '⟨') && character != '⟩';
360 }
361 boundaries.push(characters.len());
362 let byte = boundaries[random.below(boundaries.len())];
363 let offset = characters[..byte].encode_utf16().count() as u32;
364 vec![Op::Page {
365 space,
366 op: PageOp::Text {
367 text: *text,
368 range: offset..offset,
369 with: token.clone(),
370 },
371 }]
372 }
373 13..=15 => {
374 let mut after = page.clone();
375 let Some(outline) = after.objects.iter_mut().find_map(|object| match object {
376 PageObject::Outline(outline) => Some(outline),
377 _ => None,
378 }) else {
379 return;
380 };
381 let top: Vec<usize> = (0..outline.paragraphs.len())
382 .filter(|at| outline.paragraphs[*at].parent.is_none())
383 .collect();
384 let at = top[random.below(top.len())];
385 outline.paragraphs.insert(at + 1, paragraph(&token));
386 lower_page(&page, &after)
387 .unwrap()
388 .into_iter()
389 .map(|op| Op::Page { space, op })
390 .collect()
391 }
392 16 => {
393 // First or last, as a series starts at the first page.
394 let before = (random.below(2) == 0).then_some(pages[0].0);
395 let creation = PageCreation::new(before, Some(&token), &self.name).unwrap();
396 let space = creation.space();
397 return self.apply(
398 at,
399 vec![Op::Section(SectionOp::Create(creation))],
400 typed,
401 (token, space),
402 );
403 }
404 17 if pages.len() > 1 => {
405 typed.deleted.insert(space);
406 vec![Op::Section(SectionOp::Delete(vec![space]))]
407 }
408 _ => {
409 let before = pages
410 .get(random.below(pages.len() + 1))
411 .map(|page| page.0)
412 .filter(|before| *before != space);
413 let first = pages[0].0;
414 // A page going first stays at the top level.
415 let level = if before == Some(first) {
416 1
417 } else {
418 1 + random.below(2) as u32
419 };
420 vec![Op::Section(SectionOp::Pages(vec![
421 PageEdit::move_to(space, before, level).unwrap(),
422 ]))]
423 }
424 };
425 self.apply(at, ops, typed, (token, space));
426 }
427
428 /// Applies an edit, noting the string it typed once the section takes it.
429 fn apply(&self, at: u64, ops: Vec<Op>, typed: &mut Typed, token: (String, ExGuid)) {
430 let text = matches!(
431 ops.first(),
432 Some(Op::Page { .. } | Op::Section(SectionOp::Create(_)))
433 );
434 match self.replica.apply(&self.name, Edit { at, ops }) {
435 Ok(_) if text => typed.strings.push(token),
436 Ok(_) => {}
437 Err(notebook::Error::Rejected(error)) if !text => {
438 let _ = error;
439 }
440 Err(error) => panic!("{}: {error}", self.name),
441 }
442 }
443}
444
445fn crate_now() -> u64 {
446 let unix = std::time::SystemTime::now()
447 .duration_since(std::time::UNIX_EPOCH)
448 .unwrap();
449 (unix.as_secs() + 11_644_473_600) * 10_000_000
450}
451
452/// Every text on the section's pages, and on its conflict pages.
453fn texts_of(image: &[u8]) -> (String, String) {
454 let arena = onestore::Arena::default();
455 let mut section = Section::open(&arena, image.to_vec()).unwrap();
456 let mut listed = String::new();
457 for (space, title, _) in section.pages().unwrap() {
458 listed += &title;
459 listed += "\n";
460 for (_, text) in bodies(&section.page(space).unwrap()) {
461 listed += &text;
462 listed += "\n";
463 }
464 }
465 let mut conflicts = String::new();
466 for (_, pages) in section.conflicts().unwrap() {
467 for conflict in pages {
468 let page = section.page(conflict.space).unwrap();
469 conflicts += &page.title;
470 for (_, text) in bodies(&page) {
471 conflicts += &text;
472 conflicts += "\n";
473 }
474 }
475 }
476 (listed, conflicts)
477}
478
479/// Runs a schedule of `events` among `count` devices from `seed`, then lets every device
480/// come online until nothing changes, and checks where they ended.
481fn run(seed: u64, count: usize, events: usize) {
482 let source = fixture();
483 let directory = tempfile::tempdir().unwrap();
484 let mut server = Server {
485 current: source.clone(),
486 id: 0,
487 next: 0,
488 conflicts: Vec::new(),
489 kept: 0,
490 };
491 let mut devices: Vec<Device> = (0..count)
492 .map(|n| Device {
493 name: format!("D{n}"),
494 copy: Copy {
495 bytes: source.clone(),
496 base: 0,
497 dirty: false,
498 versions: Vec::new(),
499 online: true,
500 },
501 replica: Replica::create(directory.path().join(format!("{n}.sqlite")), &source)
502 .unwrap(),
503 })
504 .collect();
505 let mut random = Random(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1);
506 let mut typed = Typed::default();
507 let mut serial = 0;
508 let trace = std::env::var("CLOUD_TRACE").ok();
509 for event in 0..events {
510 let index = random.below(count);
511 let device = &mut devices[index];
512 let kind = random.below(12);
513 match kind {
514 0..=3 => device.edit(&mut random, &mut typed, &mut serial),
515 4..=5 => device.sync(&mut server),
516 6..=7 => device.upload(&mut server, &mut random),
517 8 => device.download(&mut server, &mut random),
518 9 => device.deliver(&server),
519 _ => device.copy.online = !device.copy.online,
520 }
521 if let Some(token) = &trace {
522 report(
523 token,
524 &format!("{event} D{index} {kind}"),
525 &server,
526 &devices,
527 );
528 }
529 }
530 // Everyone online until a whole round changes nothing.
531 for device in &mut devices {
532 device.copy.online = true;
533 }
534 let mut rounds = 0;
535 loop {
536 rounds += 1;
537 assert!(rounds < 64, "seed {seed}: no quiescence");
538 let before = (server.id, server.conflicts.len());
539 for device in 0..devices.len() {
540 devices[device].download(&mut server, &mut random);
541 devices[device].deliver(&server);
542 devices[device].sync(&mut server);
543 devices[device].upload(&mut server, &mut random);
544 if let Some(token) = &trace {
545 report(
546 token,
547 &format!("round {rounds} D{device}"),
548 &server,
549 &devices,
550 );
551 }
552 }
553 let settled = server.conflicts.is_empty()
554 && (server.id, server.conflicts.len()) == before
555 && devices.iter().all(|device| {
556 !device.copy.dirty
557 && device.copy.base == server.id
558 && device.replica.pending().unwrap().is_empty()
559 });
560 if settled {
561 break;
562 }
563 }
564 for device in &devices {
565 assert_eq!(
566 device.copy.bytes, server.current,
567 "seed {seed}: {}",
568 device.name
569 );
570 }
571 assert_eq!(server.kept, 0, "seed {seed}: a version could not merge");
572 let (listed, conflicts) = texts_of(&server.current);
573 for (string, space) in &typed.strings {
574 // A page deleted on one device while another typed into it comes back as a copy, one
575 // for the version merged and one for the edits queued since, if both hold edits.
576 if typed.deleted.contains(space) {
577 continue;
578 }
579 let shown = listed.matches(string.as_str()).count();
580 assert!(
581 shown <= 1,
582 "seed {seed}: {string} shows {shown} times:\n{listed}"
583 );
584 assert!(
585 shown == 1 || conflicts.contains(string.as_str()),
586 "seed {seed}: {string} is gone:\n{listed}\n--\n{conflicts}"
587 );
588 }
589}
590
591#[test]
592fn devices_on_a_cloud_drive_converge_with_every_edit_once() {
593 for seed in sweep::seeds(0..200, 60) {
594 run(seed, 3, 60);
595 }
596}
597
598#[test]
599fn two_devices_merging_one_version_at_once_add_it_once() {
600 let source = fixture();
601 let directory = tempfile::tempdir().unwrap();
602 let mut server = Server {
603 current: source.clone(),
604 id: 0,
605 next: 0,
606 conflicts: Vec::new(),
607 kept: 0,
608 };
609 let mut devices: Vec<Device> = (0..3)
610 .map(|n| Device {
611 name: format!("D{n}"),
612 copy: Copy {
613 bytes: source.clone(),
614 base: 0,
615 dirty: false,
616 versions: Vec::new(),
617 online: true,
618 },
619 replica: Replica::create(directory.path().join(format!("{n}.sqlite")), &source)
620 .unwrap(),
621 })
622 .collect();
623 let space = devices[0].replica.pages().unwrap()[0].0;
624 let type_at = |device: &Device, token: &str| {
625 let page = device.replica.page(space).unwrap();
626 let (text, _) = bodies(&page)[0].clone();
627 device
628 .replica
629 .apply(
630 &device.name,
631 Edit {
632 at: crate_now(),
633 ops: vec![Op::Page {
634 space,
635 op: PageOp::Text {
636 text,
637 range: 0..0,
638 with: token.into(),
639 },
640 }],
641 },
642 )
643 .unwrap();
644 };
645 // D0 and D1 edit the same page; D1's upload loses and becomes a conflict version.
646 type_at(&devices[0], "⟨zero⟩");
647 type_at(&devices[1], "⟨one⟩");
648 let mut random = Random(1);
649 for device in &mut devices[..2] {
650 device.sync(&mut server);
651 }
652 devices[0].upload(&mut server, &mut random);
653 devices[1].upload(&mut server, &mut random);
654 assert_eq!(server.conflicts.len(), 1);
655 // D0 and D2 both see the version and merge it before either hears the other did.
656 for device in [0, 2] {
657 devices[device].download(&mut server, &mut random);
658 devices[device].deliver(&server);
659 }
660 for device in [0, 2] {
661 devices[device].sync(&mut server);
662 }
663 assert!(server.conflicts.is_empty());
664 devices[0].upload(&mut server, &mut random);
665 // D2's merge lands on the file D0's already changed: a conflict version again.
666 devices[2].upload(&mut server, &mut random);
667 assert_eq!(server.conflicts.len(), 1);
668 for _ in 0..8 {
669 for device in &mut devices {
670 device.download(&mut server, &mut random);
671 device.deliver(&server);
672 device.sync(&mut server);
673 device.upload(&mut server, &mut random);
674 }
675 }
676 assert!(server.conflicts.is_empty());
677 let (listed, conflicts) = texts_of(&server.current);
678 for token in ["⟨zero⟩", "⟨one⟩"] {
679 let shown = listed.matches(token).count() + conflicts.matches(token).count();
680 assert!(
681 (1..=2).contains(&shown) && listed.matches(token).count() <= 1,
682 "{token} shows {shown} times:\n{listed}\n--\n{conflicts}"
683 );
684 }
685 let arena = onestore::Arena::default();
686 let mut section = Section::open(&arena, server.current.clone()).unwrap();
687 let conflict_pages: usize = section
688 .conflicts()
689 .unwrap()
690 .iter()
691 .map(|(_, pages)| pages.len())
692 .sum();
693 assert!(conflict_pages <= 1, "{conflict_pages} conflict pages");
694}
695
696/// A version of a OneNote 2010 file merges into it: an edit to another paragraph replays, a
697/// page the version made comes along, and an edit both made at one place becomes a conflict
698/// page. `ONESTORE_ICLOUD_EXPORT` names a folder to write the merged notebook to, for a cold
699/// read in OneNote 2010.
700#[test]
701fn a_version_of_a_onenote_file_merges_into_it() {
702 let notebook = format!(
703 "{}/../../corpus/conflict-page/native/initial/notebook",
704 env!("CARGO_MANIFEST_DIR")
705 );
706 let source = std::fs::read(format!("{notebook}/synthetic.one")).unwrap();
707 let directory = tempfile::tempdir().unwrap();
708 let mut server = Server {
709 current: source.clone(),
710 id: 0,
711 next: 0,
712 conflicts: Vec::new(),
713 kept: 0,
714 };
715 let mut devices: Vec<Device> = ["Mac", "iPhone"]
716 .into_iter()
717 .enumerate()
718 .map(|(n, name)| Device {
719 name: name.into(),
720 copy: Copy {
721 bytes: source.clone(),
722 base: 0,
723 dirty: false,
724 versions: Vec::new(),
725 online: true,
726 },
727 replica: Replica::create(directory.path().join(format!("{n}.sqlite")), &source)
728 .unwrap(),
729 })
730 .collect();
731 let space = devices[0].replica.pages().unwrap()[0].0;
732 let texts = bodies(&devices[0].replica.page(space).unwrap());
733 let (first, second) = (texts[0].clone(), texts[1].clone());
734 let typing = |text: ExGuid, at: u32, with: &str| Op::Page {
735 space,
736 op: PageOp::Text {
737 text,
738 range: at..at,
739 with: with.into(),
740 },
741 };
742 let apply = |device: &Device, ops: Vec<Op>| {
743 device
744 .replica
745 .apply(
746 &device.name,
747 Edit {
748 at: crate_now(),
749 ops,
750 },
751 )
752 .unwrap();
753 };
754 apply(&devices[0], vec![typing(first.0, 0, "Mac: ")]);
755 let end = second.1.encode_utf16().count() as u32;
756 apply(
757 &devices[1],
758 vec![
759 typing(first.0, 0, "iPhone: "),
760 typing(second.0, end, " (typed on the iPhone)"),
761 ],
762 );
763 let creation = PageCreation::new(None, Some("From the iPhone"), "iPhone").unwrap();
764 let made = creation.space();
765 apply(&devices[1], vec![Op::Section(SectionOp::Create(creation))]);
766 let page = devices[1].replica.page(made).unwrap();
767 let body = with_body(&page, "Written offline on the iPhone.");
768 apply(
769 &devices[1],
770 lower_page(&page, &body)
771 .unwrap()
772 .into_iter()
773 .map(|op| Op::Page { space: made, op })
774 .collect(),
775 );
776 let mut random = Random(3);
777 for device in &mut devices {
778 device.sync(&mut server);
779 device.upload(&mut server, &mut random);
780 }
781 assert_eq!(server.conflicts.len(), 1);
782 let version = server.conflicts[0].clone();
783 for _ in 0..6 {
784 for device in &mut devices {
785 device.download(&mut server, &mut random);
786 device.deliver(&server);
787 device.sync(&mut server);
788 device.upload(&mut server, &mut random);
789 }
790 }
791 assert!(server.conflicts.is_empty());
792 assert!(
793 devices
794 .iter()
795 .all(|device| device.copy.bytes == server.current)
796 );
797 // The version once more, as a device that has not heard it was resolved sees it: merged
798 // already, it adds nothing.
799 devices[1].copy.versions = vec![version];
800 devices[1].sync(&mut server);
801 assert!(!devices[1].copy.dirty && devices[1].copy.versions.is_empty());
802 let (listed, conflicts) = texts_of(&server.current);
803 assert!(listed.contains("(typed on the iPhone)"), "{listed}");
804 assert!(
805 listed.contains("Written offline on the iPhone."),
806 "{listed}"
807 );
808 let arena = onestore::Arena::default();
809 let merged = Section::open(&arena, server.current.clone()).unwrap();
810 let shown: String = bodies(&merged.page(space).unwrap())
811 .into_iter()
812 .map(|(_, text)| text + "\n")
813 .collect();
814 assert_eq!(
815 shown.matches("Mac: ").count() + shown.matches("iPhone: ").count(),
816 1,
817 "{shown}"
818 );
819 assert!(
820 conflicts.contains("Mac: ") || conflicts.contains("iPhone: "),
821 "{conflicts}"
822 );
823 if let Some(output) = std::env::var_os("ONESTORE_ICLOUD_EXPORT") {
824 let output = std::path::Path::new(&output);
825 std::fs::create_dir_all(output).unwrap();
826 std::fs::write(output.join("synthetic.one"), &server.current).unwrap();
827 std::fs::copy(
828 format!("{notebook}/Open Notebook.onetoc2"),
829 output.join("Open Notebook.onetoc2"),
830 )
831 .unwrap();
832 }
833}
834
835/// Where `token` stands after `label`, for tracing a schedule (`CLOUD_TRACE`).
836fn report(token: &str, label: &str, server: &Server, devices: &[Device]) {
837 let has = |image: &[u8]| {
838 let (listed, conflicts) = texts_of(image);
839 format!(
840 "{}{}",
841 listed.matches(token).count(),
842 conflicts.matches(token).count()
843 )
844 };
845 let mut line = format!("{label}: server {}", has(&server.current));
846 for (id, bytes, _) in &server.conflicts {
847 line += &format!(" v{id}:{}", has(bytes));
848 }
849 for device in devices {
850 let working: String = device
851 .replica
852 .pages()
853 .unwrap()
854 .iter()
855 .map(|(space, title, _)| {
856 title.clone()
857 + &bodies(&device.replica.page(*space).unwrap())
858 .into_iter()
859 .map(|(_, text)| text)
860 .collect::<String>()
861 })
862 .collect();
863 line += &format!(
864 " | {} copy {} base {} dirty {} replica {} pending {}",
865 device.name,
866 has(&device.copy.bytes),
867 device.copy.base,
868 u8::from(device.copy.dirty),
869 working.matches(token).count(),
870 device.replica.pending().unwrap().len()
871 );
872 }
873 eprintln!("{line}");
874}
crates/notebook/tests/discovery.rs+200-14
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use notebook::discover::{Cache, Entry, Error, Limits, Local, Source, discover};1use notebook::discover::{Cache, Entry, Error, Limits, Local, Reason, Source, discover};
2use std::{fs, io, path::Path};2use std::{fs, io, path::Path};
33
4fn limits() -> Limits {4fn limits() -> Limits {
...@@ -66,22 +66,138 @@ fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() {...@@ -66,22 +66,138 @@ fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() {
66 );66 );
67}67}
6868
69/// Sets `path`'s modification time to `seconds` after the epoch.
70fn touch(path: &Path, seconds: u64) {
71 fs::File::options()
72 .write(true)
73 .open(path)
74 .unwrap()
75 .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
76 .unwrap();
77}
78
79fn copy_of(unavailable: &notebook::discover::Unavailable) -> Option<&str> {
80 match &unavailable.reason {
81 Reason::Copy { of } => Some(of),
82 _ => None,
83 }
84}
85
86/// OneNote 2010 lists a copied section file beside its original
87/// (`Cross 2.one`, 2026-09-29 lab run); one replica cannot hold both, so the copy the TOC
88/// does not name lists as unavailable.
69#[test]89#[test]
70fn copied_identities_are_ambiguous_even_across_different_groups() {90#[ignore = "expects copies set aside; duplicates now open as sections"]
91fn a_copied_section_lists_as_a_copy_of_the_one_the_toc_names() {
71 let root = tempfile::tempdir().unwrap();92 let root = tempfile::tempdir().unwrap();
72 fixture(root.path());93 fixture(root.path());
73 fs::create_dir(root.path().join("group")).unwrap();94 fs::create_dir(root.path().join("group")).unwrap();
74 fs::copy(95 for copy in ["one 2.one", "group/other.one"] {
75 root.path().join("one.one"),96 fs::copy(root.path().join("one.one"), root.path().join(copy)).unwrap();
76 root.path().join("group/other.one"),97 touch(&root.path().join(copy), 2_000_000_000);
98 }
99 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
100 let paths = |folder: &notebook::discover::Folder| {
101 folder
102 .sections
103 .iter()
104 .map(|section| section.path.clone())
105 .collect::<Vec<_>>()
106 };
107 assert_eq!(paths(&catalog), ["one.one"]);
108 assert_eq!(catalog.unavailable.len(), 1);
109 assert_eq!(catalog.unavailable[0].path, "one 2.one");
110 assert_eq!(copy_of(&catalog.unavailable[0]), Some("one.one"));
111 assert!(catalog.unavailable[0].error.contains("\u{201c}one\u{201d}"));
112 let group = &catalog.groups[0];
113 assert!(paths(group).is_empty());
114 assert_eq!(group.unavailable[0].path, "group/other.one");
115 assert_eq!(copy_of(&group.unavailable[0]), Some("one.one"));
116}
117
118#[test]
119#[ignore = "expects copies set aside; duplicates now open as sections"]
120fn without_a_toc_the_newest_copy_lists() {
121 let root = tempfile::tempdir().unwrap();
122 fixture(root.path());
123 fs::remove_file(root.path().join("Open Notebook.onetoc2")).unwrap();
124 fs::copy(root.path().join("one.one"), root.path().join("one 2.one")).unwrap();
125 touch(&root.path().join("one.one"), 1_000_000_000);
126 touch(&root.path().join("one 2.one"), 2_000_000_000);
127 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
128 assert_eq!(catalog.sections.len(), 1);
129 assert_eq!(catalog.sections[0].path, "one 2.one");
130 assert_eq!(catalog.unavailable[0].path, "one.one");
131 assert_eq!(copy_of(&catalog.unavailable[0]), Some("one 2.one"));
132}
133
134#[test]
135fn a_copied_group_lists_once_with_its_sections() {
136 let root = tempfile::tempdir().unwrap();
137 fixture(root.path());
138 let group = root.path().join("Group");
139 fs::create_dir(&group).unwrap();
140 let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap();
141 fs::write(group.join("Inner.one"), &inner).unwrap();
142 let identity = onestore::Store::parse(&inner).unwrap().header.file_id;
143 fs::write(
144 group.join("Open Notebook.onetoc2"),
145 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Inner.one", identity)])
146 .unwrap(),
77 )147 )
78 .unwrap();148 .unwrap();
79 assert!(149 let copy = root.path().join("Group 2");
80 matches!(discover(&mut Local::open(root.path()).unwrap(), limits()),150 fs::create_dir(&copy).unwrap();
81 Err(Error::DuplicateIdentity { first, second, .. })151 for name in ["Inner.one", "Open Notebook.onetoc2"] {
82 if [first.as_str(), second.as_str()].contains(&"one.one")152 fs::copy(group.join(name), copy.join(name)).unwrap();
83 && [first.as_str(), second.as_str()].contains(&"group/other.one"))153 touch(&group.join(name), 1_000_000_000);
84 );154 touch(&copy.join(name), 2_000_000_000);
155 }
156 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
157 assert_eq!(catalog.groups.len(), 1);
158 assert_eq!(catalog.groups[0].path, "Group 2");
159 assert_eq!(catalog.groups[0].sections[0].path, "Group 2/Inner.one");
160 assert_eq!(catalog.unavailable.len(), 1);
161 assert_eq!(catalog.unavailable[0].path, "Group");
162 assert!(catalog.unavailable[0].group);
163 assert_eq!(copy_of(&catalog.unavailable[0]), Some("Group 2"));
164}
165
166/// iOS lists a file iCloud Drive evicted as `.Name.icloud`: the section lists as not
167/// downloaded under its own name, and nothing writes a TOC over an evicted one.
168#[test]
169fn evicted_files_list_under_their_names_until_downloaded() {
170 let temporary = tempfile::tempdir().unwrap();
171 let root = temporary.path().join("notebook");
172 fs::create_dir(&root).unwrap();
173 let section = fixture(&root);
174 fs::rename(root.join("one.one"), root.join(".one.one.icloud")).unwrap();
175 let mut source = Local::open(&root).unwrap();
176 let catalog = discover(&mut source, limits()).unwrap();
177 assert!(catalog.sections.is_empty());
178 assert_eq!(catalog.unavailable.len(), 1);
179 assert_eq!(catalog.unavailable[0].path, "one.one");
180 assert_eq!(catalog.unavailable[0].reason, Reason::Evicted);
181 // Downloading can leave the placeholder beside the file for a moment.
182 fs::write(root.join("one.one"), &section).unwrap();
183 let catalog = discover(&mut source, limits()).unwrap();
184 assert_eq!(catalog.sections[0].path, "one.one");
185 assert!(catalog.unavailable.is_empty());
186
187 fs::remove_file(root.join(".one.one.icloud")).unwrap();
188 fs::rename(
189 root.join("Open Notebook.onetoc2"),
190 root.join(".Open Notebook.onetoc2.icloud"),
191 )
192 .unwrap();
193 let catalog = discover(&mut source, limits()).unwrap();
194 assert!(catalog.toc.is_none());
195 assert_eq!(catalog.sections[0].path, "one.one");
196 let mut notebook =
197 notebook::session::Notebook::open(&root, temporary.path().join("cache")).unwrap();
198 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
199 assert!(notebook.create_section("", "Second", &page).is_err());
200 assert!(!root.join("Open Notebook.onetoc2").exists());
85}201}
86202
87#[test]203#[test]
...@@ -194,10 +310,80 @@ fn limits_and_incomplete_files_do_not_produce_a_catalog() {...@@ -194,10 +310,80 @@ fn limits_and_incomplete_files_do_not_produce_a_catalog() {
194 ),310 ),
195 Err(Error::Limit { .. })311 Err(Error::Limit { .. })
196 ));312 ));
197 fs::write(root.path().join("one.one"), b"unfinished").unwrap();313 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
198 assert!(314 assert!(
199 matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "one.one")315 matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "Open Notebook.onetoc2")
316 );
317}
318
319#[test]
320fn a_section_that_cannot_be_read_lists_as_unavailable_and_the_rest_open() {
321 /// Answers as a share does a file mid-commit, as many times as `busy` counts for its
322 /// path, and fails the path `failure` names.
323 struct Busy {
324 source: Local,
325 busy: Vec<(&'static str, usize)>,
326 failure: Option<(&'static str, io::ErrorKind)>,
327 }
328 impl Source for Busy {
329 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
330 self.source.entries(path, limit)
331 }
332 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
333 if let Some((_, kind)) = self.failure.filter(|(failing, _)| *failing == path) {
334 return Err(kind.into());
335 }
336 if let Some((_, left)) = self.busy.iter_mut().find(|(busy, _)| *busy == path)
337 && *left > 0
338 {
339 *left -= 1;
340 return Err(io::ErrorKind::WouldBlock.into());
341 }
342 self.source.read(path, limit)
343 }
344 }
345 let root = tempfile::tempdir().unwrap();
346 fixture(root.path());
347 for name in ["briefly.one", "stuck.one"] {
348 fs::write(
349 root.path().join(name),
350 onestore::create_section(name, "Text", "Fixture").unwrap(),
351 )
352 .unwrap();
353 }
354 fs::write(root.path().join("broken.one"), b"unfinished").unwrap();
355 let mut source = Busy {
356 source: Local::open(root.path()).unwrap(),
357 busy: vec![("briefly.one", 2), ("stuck.one", usize::MAX)],
358 failure: Some(("one.one", io::ErrorKind::InvalidData)),
359 };
360 let catalog = discover(&mut source, limits()).unwrap();
361 let sections: Vec<_> = catalog
362 .sections
363 .iter()
364 .map(|section| &section.path)
365 .collect();
366 assert_eq!(sections, ["briefly.one"]);
367 let unavailable: Vec<_> = catalog
368 .unavailable
369 .iter()
370 .map(|entry| (entry.path.as_str(), &entry.reason))
371 .collect();
372 assert_eq!(
373 unavailable,
374 [
375 ("broken.one", &Reason::Unreadable),
376 ("one.one", &Reason::Unreadable),
377 ("stuck.one", &Reason::InUse),
378 ]
200 );379 );
380
381 // A lost connection still fails the whole discovery.
382 source.failure = Some(("one.one", io::ErrorKind::ConnectionAborted));
383 assert!(matches!(
384 discover(&mut source, limits()),
385 Err(Error::Io { path, error }) if path == "one.one" && error.kind() == io::ErrorKind::ConnectionAborted
386 ));
201}387}
202388
203#[test]389#[test]
...@@ -432,7 +618,7 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() {...@@ -432,7 +618,7 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
432 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());618 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());
433619
434 // A failed discovery leaves the cache as it was.620 // A failed discovery leaves the cache as it was.
435 fs::write(root.path().join("one.one"), b"unfinished").unwrap();621 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
436 assert!(kept.discover(&mut source, limits()).is_err());622 assert!(kept.discover(&mut source, limits()).is_err());
437 assert!(kept.found("two.one").is_some());623 assert!(kept.found("two.one").is_some());
438}624}
crates/notebook/tests/location.rs created+253
...@@ -0,0 +1,253 @@
1//! Replicas are kept per notebook location, so copies of a notebook never share one.
2
3use notebook::session::{Notebook, Section};
4use onestore::{
5 ExGuid,
6 op::{Edit, Op, PageOp},
7 page::{Page, PageObject},
8};
9use std::{
10 path::{Path, PathBuf},
11 time::{Duration, Instant},
12};
13
14/// A notebook folder `name` in `directory` holding `First.one` and `Second.one`.
15fn notebook(directory: &Path, name: &str) -> PathBuf {
16 let root = directory.join(name);
17 std::fs::create_dir(&root).unwrap();
18 for section in ["First", "Second"] {
19 let file = format!("{section}.one");
20 std::fs::write(
21 root.join(&file),
22 onestore::create_section(&file, section, "Author").unwrap(),
23 )
24 .unwrap();
25 }
26 root
27}
28
29/// Copies the folder `from` to `to`, as Finder or Files duplicates a notebook.
30fn duplicate(from: &Path, to: &Path) {
31 std::fs::create_dir(to).unwrap();
32 for entry in std::fs::read_dir(from).unwrap() {
33 let entry = entry.unwrap();
34 std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap();
35 }
36}
37
38/// The first text of the section's first page, with its id and the page's space.
39fn first(page: &Page) -> (ExGuid, String) {
40 page.objects
41 .iter()
42 .find_map(|object| match object {
43 PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|paragraph| {
44 let text = paragraph.text()?;
45 Some((text.id, text.text.text().to_string()))
46 }),
47 _ => None,
48 })
49 .unwrap()
50}
51
52/// Types `text` at the start of the section's first page; the page's space.
53fn typed(section: &Section, text: &str) -> ExGuid {
54 let space = section.pages().unwrap()[0].0;
55 let (id, _) = first(&section.page(space).unwrap());
56 let edit = Edit {
57 at: (std::time::SystemTime::now()
58 .duration_since(std::time::UNIX_EPOCH)
59 .unwrap()
60 .as_secs()
61 + 11_644_473_600)
62 * 10_000_000,
63 ops: vec![Op::Page {
64 space,
65 op: PageOp::Text {
66 text: id,
67 range: 0..0,
68 with: text.into(),
69 },
70 }],
71 };
72 section.replica().apply("Editor", edit).unwrap();
73 space
74}
75
76fn stored(file: &Path, space: ExGuid) -> String {
77 let arena = onestore::Arena::default();
78 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
79 first(&section.page(space).unwrap()).1
80}
81
82fn until(what: &str, mut accept: impl FnMut() -> bool) {
83 let deadline = Instant::now() + Duration::from_secs(20);
84 while !accept() {
85 assert!(Instant::now() < deadline, "{what}");
86 std::thread::sleep(Duration::from_millis(20));
87 }
88}
89
90/// Types `text` into `First.one` of `root` while working offline and closes it, leaving the
91/// edit queued in its replica.
92fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {
93 let notebook = Notebook::open(root, cache).unwrap();
94 let section = notebook.section("First.one", || {}).unwrap();
95 section.set_offline(true);
96 let space = typed(&section, text);
97 assert_eq!(section.pending().unwrap().len(), 1);
98 section.close().unwrap();
99 space
100}
101
102/// Opens `First.one` of `root` and waits for it to publish what it holds.
103fn published(root: &Path, cache: &Path) -> usize {
104 let notebook = Notebook::open(root, cache).unwrap();
105 let section = notebook.section("First.one", || {}).unwrap();
106 let pending = section.pending().unwrap().len();
107 until("the queue published", || {
108 section.sync_status().unwrap().queued == 0
109 });
110 section.close().unwrap();
111 pending
112}
113
114#[test]
115fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves() {
116 let directory = tempfile::tempdir().unwrap();
117 let cache = directory.path().join("cache");
118 let original = notebook(directory.path(), "Notebook");
119 let copy = directory.path().join("Notebook 2");
120 duplicate(&original, &copy);
121 let (a, b) = (
122 Notebook::open(&original, &cache).unwrap(),
123 Notebook::open(&copy, &cache).unwrap(),
124 );
125 assert_ne!(
126 a.replica_path("First.one").unwrap(),
127 b.replica_path("First.one").unwrap()
128 );
129 // Both open at once: neither waits on the other's replica.
130 let (mine, theirs) = (
131 a.section("First.one", || {}).unwrap(),
132 b.section("First.one", || {}).unwrap(),
133 );
134 mine.set_offline(true);
135 let space = typed(&mine, "Original ");
136 theirs.wake();
137 until("the copy synced", || {
138 theirs.sync_status().unwrap().synced.is_some()
139 });
140 assert!(theirs.pending().unwrap().is_empty());
141 mine.set_offline(false);
142 until("the original published", || {
143 stored(&original.join("First.one"), space).starts_with("Original ")
144 });
145 assert_eq!(stored(&copy.join("First.one"), space), "First");
146 assert_eq!(first(&theirs.page(space).unwrap()).1, "First");
147 mine.close().unwrap();
148 theirs.close().unwrap();
149}
150
151#[test]
152fn a_notebook_the_app_moves_keeps_its_queued_edits() {
153 let directory = tempfile::tempdir().unwrap();
154 let cache = directory.path().join("cache");
155 let root = notebook(directory.path(), "Notebook");
156 let space = queued(&root, &cache, "Queued ");
157 let from = notebook::location::local(&root).unwrap();
158 let renamed = directory.path().join("Renamed");
159 std::fs::rename(&root, &renamed).unwrap();
160 // A new notebook where the old one was: its location still exists, so only moving the
161 // replicas explicitly carries the queue.
162 notebook(directory.path(), "Notebook");
163 let to = notebook::location::local(&renamed).unwrap();
164 notebook::location::moved(&cache, &from, &to).unwrap();
165 assert_eq!(published(&renamed, &cache), 1);
166 assert!(stored(&renamed.join("First.one"), space).starts_with("Queued "));
167 assert_eq!(published(&root, &cache), 0);
168}
169
170#[test]
171fn a_notebook_moved_outside_the_app_takes_its_replicas_along() {
172 let directory = tempfile::tempdir().unwrap();
173 let cache = directory.path().join("cache");
174 let root = notebook(directory.path(), "Notebook");
175 let space = queued(&root, &cache, "Queued ");
176 let moved = directory.path().join("Moved");
177 std::fs::rename(&root, &moved).unwrap();
178 assert_eq!(published(&moved, &cache), 1);
179 assert!(stored(&moved.join("First.one"), space).starts_with("Queued "));
180}
181
182#[test]
183fn a_replica_newer_than_a_surviving_copy_stays_behind() {
184 let directory = tempfile::tempdir().unwrap();
185 let cache = directory.path().join("cache");
186 let root = notebook(directory.path(), "Notebook");
187 let copy = directory.path().join("Copy");
188 duplicate(&root, &copy);
189 // The original moves on past the copy, then queues an edit and goes away.
190 {
191 let notebook = Notebook::open(&root, &cache).unwrap();
192 let section = notebook.section("First.one", || {}).unwrap();
193 typed(&section, "Published ");
194 until("the original published", || {
195 section.sync_status().unwrap().queued == 0
196 });
197 section.close().unwrap();
198 }
199 queued(&root, &cache, "Queued ");
200 std::fs::remove_dir_all(&root).unwrap();
201 assert_eq!(published(&copy, &cache), 0);
202 let space = Notebook::open(&copy, &cache)
203 .unwrap()
204 .section("First.one", || {})
205 .unwrap()
206 .pages()
207 .unwrap()[0]
208 .0;
209 assert_eq!(stored(&copy.join("First.one"), space), "First");
210}
211
212#[test]
213fn a_replica_named_by_identity_alone_moves_to_the_first_location_that_opens_it() {
214 let directory = tempfile::tempdir().unwrap();
215 let cache = directory.path().join("cache");
216 let root = notebook(directory.path(), "Notebook");
217 queued(&root, &cache, "Queued ");
218 let replica = Notebook::open(&root, &cache)
219 .unwrap()
220 .replica_path("First.one")
221 .unwrap();
222 let legacy = cache.join(replica.file_name().unwrap());
223 std::fs::rename(&replica, &legacy).unwrap();
224 assert_eq!(published(&root, &cache), 1);
225 assert!(!legacy.exists());
226}
227
228#[test]
229#[ignore = "expects copies set aside; duplicates now open as sections"]
230fn a_section_copy_beside_its_original_opens_only_as_a_lone_file_with_its_own_replica() {
231 let directory = tempfile::tempdir().unwrap();
232 let cache = directory.path().join("cache");
233 let root = notebook(directory.path(), "Notebook");
234 std::fs::copy(root.join("First.one"), root.join("First 2.one")).unwrap();
235 let mut notebook = Notebook::open(&root, &cache).unwrap();
236 // Discovery lists the copy as unavailable, so one replica serves the section.
237 assert!(notebook.section("First 2.one", || {}).is_err());
238 assert_eq!(
239 notebook
240 .replicas()
241 .iter()
242 .filter(|known| known.path.starts_with("First"))
243 .count(),
244 1
245 );
246 let original = Section::open(root.join("First.one"), &cache, || {}).unwrap();
247 let copy = Section::open(root.join("First 2.one"), &cache, || {}).unwrap();
248 original.set_offline(true);
249 typed(&original, "Mine ");
250 assert!(copy.pending().unwrap().is_empty());
251 original.close().unwrap();
252 copy.close().unwrap();
253}
crates/notebook/tests/session.rs+35-12
...@@ -205,12 +205,10 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() {...@@ -205,12 +205,10 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() {
205 section.close().unwrap();205 section.close().unwrap();
206 assert_eq!(206 assert_eq!(
207 notebook::Replica::open(207 notebook::Replica::open(
208 std::fs::read_dir(&cache)208 walkdir(&cache)
209 .unwrap()209 .into_iter()
210 .next()210 .find(|path| path.extension().is_some_and(|ext| ext == "sqlite"))
211 .unwrap()211 .expect("the section's replica")
212 .unwrap()
213 .path()
214 )212 )
215 .unwrap()213 .unwrap()
216 .status(id)214 .status(id)
...@@ -386,11 +384,13 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {...@@ -386,11 +384,13 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
386 section.close().unwrap();384 section.close().unwrap();
387}385}
388386
389/// The replicas in `cache`, beside the folder of catalog listings.387/// The replicas in `cache`, in the folder of each location.
390fn replicas(cache: &Path) -> usize {388fn replicas(cache: &Path) -> usize {
391 std::fs::read_dir(cache)389 std::fs::read_dir(cache.join("replicas"))
392 .unwrap()390 .into_iter()
393 .filter(|entry| entry.as_ref().unwrap().path().is_file())391 .flatten()
392 .flat_map(|folder| std::fs::read_dir(folder.unwrap().path()).unwrap())
393 .filter(|entry| entry.as_ref().unwrap().path().extension() == Some("sqlite".as_ref()))
394 .count()394 .count()
395}395}
396396
...@@ -1200,8 +1200,17 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {...@@ -1200,8 +1200,17 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1200 !notebook.replica_path("First.one").unwrap().exists()1200 !notebook.replica_path("First.one").unwrap().exists()
1201 });1201 });
1202 assert!(second.exists(), "a copy with edits waiting stays");1202 assert!(second.exists(), "a copy with edits waiting stays");
1203 let kept = notebook::Replica::open(&second).unwrap();1203 // The stopping thread may still be peeking at it, as a host opening a section waits out.
1204 assert_eq!(kept.pending().unwrap().len(), 1);1204 let mut kept = None;
1205 until("the background let go of the copy", || {
1206 match notebook::Replica::open(&second) {
1207 Ok(replica) => kept = Some(replica),
1208 Err(error) if error.busy() => {}
1209 Err(error) => panic!("{error}"),
1210 }
1211 kept.is_some()
1212 });
1213 assert_eq!(kept.unwrap().pending().unwrap().len(), 1);
1205}1214}
12061215
1207/// A section file whose stamp reads are counted.1216/// A section file whose stamp reads are counted.
...@@ -1368,3 +1377,17 @@ fn the_background_takes_each_file_discovery_read_rather_than_reading_it_again()...@@ -1368,3 +1377,17 @@ fn the_background_takes_each_file_discovery_read_rather_than_reading_it_again()
1368 let rebased = notebook::Replica::open(&replica).unwrap();1377 let rebased = notebook::Replica::open(&replica).unwrap();
1369 assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space));1378 assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space));
1370}1379}
1380
1381/// Every file under `dir`, depth first.
1382fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
1383 let mut files = Vec::new();
1384 for entry in std::fs::read_dir(dir).unwrap() {
1385 let path = entry.unwrap().path();
1386 if path.is_dir() {
1387 files.extend(walkdir(&path));
1388 } else {
1389 files.push(path);
1390 }
1391 }
1392 files
1393}
crates/onestore/README.md+3-2
...@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`....@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`.
46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |
47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |
48| `page::Recording`, `page::MediaIndex`, `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 |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` |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 with each point's pen pressure, a moved drawing offset by its position, a highlighter's raster operation, a drawn shape's kind and anchors) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |
...@@ -62,6 +62,7 @@ harness also accepts `--client-profile release`....@@ -62,6 +62,7 @@ harness also accepts `--client-profile release`.
62| `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal, conflict pages) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault |62| `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal, conflict pages) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault |
63| `SectionOp::Conflict`, `Section::conflicts`, `ConflictPage` | Keep the version of a page a merge could not take as OneNote 2010 does: a read-only conflict page (`jcidConflictPageMetaData`, `IsConflictPage`, conflict objects marked) under the page's manifest, which says it has conflict pages; list each page's, newest first, and delete one (`SectionOp::Delete`) as OneNote's Delete Conflict Page does |63| `SectionOp::Conflict`, `Section::conflicts`, `ConflictPage` | Keep the version of a page a merge could not take as OneNote 2010 does: a read-only conflict page (`jcidConflictPageMetaData`, `IsConflictPage`, conflict objects marked) under the page's manifest, which says it has conflict pages; list each page's, newest first, and delete one (`SectionOp::Delete`) as OneNote's Delete Conflict Page does |
64| `Section::versions`, `Section::version`, `PageVersion`, `SectionOp::RestoreVersion`, `SectionOp::DeleteVersions` | A page's versions as OneNote 2010 keeps them: earlier revisions of the page's own space, each current under its own context and listed by the page's version history, newest first with its author and time; read one as a page (O(section)); restore one as Restore Version does (the page's next revision builds on it and the page as it stood becomes the newest version) or unlist some as Delete Version does, their revisions staying stored ([corpus](../../corpus/page-versions/README.md)) |64| `Section::versions`, `Section::version`, `PageVersion`, `SectionOp::RestoreVersion`, `SectionOp::DeleteVersions` | A page's versions as OneNote 2010 keeps them: earlier revisions of the page's own space, each current under its own context and listed by the page's version history, newest first with its author and time; read one as a page (O(section)); restore one as Restore Version does (the page's next revision builds on it and the page as it stood becomes the newest version) or unlist some as Delete Version does, their revisions staying stored ([corpus](../../corpus/page-versions/README.md)) |
65| `Section::page_at`, `Section::open_at`, `Section::seal_as` | Read a page as any revision the file stores holds it, or the section as chosen revisions of its spaces leave it (a merge's common ancestor); seal revisions under chosen identities so a later merge recognises them |
65| `op::lower`, `op::lower_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone, for editors and imports |66| `op::lower`, `op::lower_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone, for editors and imports |
66| `op::predict` | The page an op leaves, as the section stores it and reading it back shows it |67| `op::predict` | The page an op leaves, as the section stores it and reading it back shows it |
67| `read_file` | Read a snapshot under whole-file exclusion |68| `read_file` | Read a snapshot under whole-file exclusion |
...@@ -87,7 +88,7 @@ rejects protected sections....@@ -87,7 +88,7 @@ rejects protected sections.
87`PageCreation::new` appends, or inserts before the first page space of an existing88`PageCreation::new` appends, or inserts before the first page space of an existing
88series. `Some("")` creates an empty title field; `None` omits the title node.89series. `Some("")` creates an empty title field; `None` omits the title node.
89`dated(date, time)` gives the title OneNote 2010's date and time fields showing that90`dated(date, time)` gives the title OneNote 2010's date and time fields showing that
90text, as OneNote titles a new page; `keeping(identity, created)` keeps another page's91text, as OneNote titles a new page; `in_space(guid)` creates the page in the space `{guid},1` with a series named after it, as a merge that must make the same page every time does; `keeping(identity, created)` keeps another page's
91identity and creation time, as a page moved to the recycle bin keeps them. The page has92identity and creation time, as a page moved to the recycle bin keeps them. The page has
92no body outlines or applied template; `create_empty_section` makes a section for such93no body outlines or applied template; `create_empty_section` makes a section for such
93pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node,94pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node,
crates/onestore/examples/scratch_pressure.rs created+48
...@@ -0,0 +1,48 @@
1use onestore::{RevisionIndex, Store, document::{Document, Kind}};
2fn mb(bytes: &[u8]) -> Vec<i64> {
3 let mut out = Vec::new(); let mut v = 0u64; let mut s = 0;
4 for &b in bytes { v |= u64::from(b & 0x7f) << s; s += 7; if b & 0x80 == 0 { out.push(v as i64); v = 0; s = 0; } }
5 let count = (out[0] >> 1) as usize;
6 out[1..].iter().take(count).map(|r| if r & 1 == 1 { -(r >> 1) } else { r >> 1 }).collect()
7}
8fn guid(b: &[u8]) -> String {
9 let d1 = u32::from_le_bytes(b[0..4].try_into().unwrap());
10 let d2 = u16::from_le_bytes(b[4..6].try_into().unwrap());
11 let d3 = u16::from_le_bytes(b[6..8].try_into().unwrap());
12 format!("{d1:08x}-{d2:04x}-{d3:04x}-{}", b[8..].iter().map(|x| format!("{x:02x}")).collect::<String>())
13}
14fn main() {
15 let path = std::env::args().nth(1).unwrap();
16 let bytes = std::fs::read(path).unwrap();
17 let store = Store::parse(&bytes).unwrap();
18 let index = RevisionIndex::parse(&store).unwrap();
19 let document = Document::parse(&index).unwrap();
20 for (space, _) in document.pages().unwrap() {
21 let rev = document.active(space).unwrap();
22 for (id, node) in &rev.nodes {
23 match &node.kind {
24 Kind::InkStyle { dimensions, .. } => {
25 println!("STYLE {id:?} {:?}", node.kind);
26 for d in dimensions.chunks_exact(32) {
27 let lo = i32::from_le_bytes(d[16..20].try_into().unwrap());
28 let hi = i32::from_le_bytes(d[20..24].try_into().unwrap());
29 let units = u32::from_le_bytes(d[24..28].try_into().unwrap());
30 let res = f32::from_le_bytes(d[28..32].try_into().unwrap());
31 println!(" dim {} [{lo}, {hi}] units {units} res {res}", guid(&d[..16]));
32 }
33 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
34 }
35 Kind::InkStroke { path, style, bias, index, .. } => {
36 let v = mb(path);
37 println!("STROKE {id:?} style {style:?} bias {bias:?} index {index:?} n={} values {:?}", v.len(), v);
38 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
39 }
40 Kind::Ink { .. } | Kind::InkData { .. } => {
41 println!("{id:?} {:?} layout {:?}", node.kind, node.layout);
42 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
43 }
44 _ => {}
45 }
46 }
47 }
48}
crates/onestore/src/op/content.rs+28-6
...@@ -239,7 +239,14 @@ pub(crate) fn picture_changes(...@@ -239,7 +239,14 @@ pub(crate) fn picture_changes(
239 values.push((0x08001d13 | (1 << 31), Vec::new()));239 values.push((0x08001d13 | (1 << 31), Vec::new()));
240 }240 }
241 values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec()));241 values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec()));
242 values.push((0x08001d85, Vec::new()));242 // IsPrintout, and the printout page shown (DisplayedPageNumber).
243 values.push((
244 0x08001d85 | (u32::from(image.printout.is_some()) << 31),
245 Vec::new(),
246 ));
247 if let Some(page) = image.printout {
248 values.push((0x14001df9, page.to_le_bytes().to_vec()));
249 }
243 let mut changed = BTreeMap::new();250 let mut changed = BTreeMap::new();
244 changed.insert(251 changed.insert(
245 file,252 file,
...@@ -555,7 +562,8 @@ fn write_strokes(...@@ -555,7 +562,8 @@ fn write_strokes(
555}562}
556563
557/// The drawing attributes OneNote shares between strokes drawn with the same pen. A pen564/// The drawing attributes OneNote shares between strokes drawn with the same pen. A pen
558/// drawn with the mouse ignores pressure; a shape's pen spans every coordinate instead.565/// drawn with the mouse ignores pressure, and one that follows it records it as a third
566/// dimension (`corpus/ink-pressure`); a shape's pen spans every coordinate instead.
559#[derive(PartialEq)]567#[derive(PartialEq)]
560struct InkPen {568struct InkPen {
561 width: u32,569 width: u32,
...@@ -565,6 +573,7 @@ struct InkPen {...@@ -565,6 +573,7 @@ struct InkPen {
565 pen_tip: Option<u8>,573 pen_tip: Option<u8>,
566 raster_operation: Option<u8>,574 raster_operation: Option<u8>,
567 shape: bool,575 shape: bool,
576 pressure: bool,
568}577}
569578
570impl InkPen {579impl InkPen {
...@@ -577,17 +586,22 @@ impl InkPen {...@@ -577,17 +586,22 @@ impl InkPen {
577 pen_tip: stroke.pen_tip,586 pen_tip: stroke.pen_tip,
578 raster_operation: stroke.raster_operation,587 raster_operation: stroke.raster_operation,
579 shape,588 shape,
589 pressure: !stroke.pressure.is_empty(),
580 }590 }
581 }591 }
582592
583 fn values(&self) -> Values {593 fn values(&self) -> Values {
584 let dimensions = if self.shape {594 let mut dimensions = if self.shape {
585 crate::page::ink::SHAPE_DIMENSIONS595 crate::page::ink::SHAPE_DIMENSIONS
586 } else {596 } else {
587 crate::page::ink::DIMENSIONS597 crate::page::ink::DIMENSIONS
588 };598 }
599 .to_vec();
600 if self.pressure {
601 dimensions.extend(crate::page::ink::PRESSURE_DIMENSION);
602 }
589 let mut values: Values = vec![603 let mut values: Values = vec![
590 (0x1c00340a, dimensions.to_vec()),604 (0x1c00340a, dimensions),
591 (0x1400340c, self.height.to_le_bytes().to_vec()),605 (0x1400340c, self.height.to_le_bytes().to_vec()),
592 (0x1400340d, self.width.to_le_bytes().to_vec()),606 (0x1400340d, self.width.to_le_bytes().to_vec()),
593 ];607 ];
...@@ -603,7 +617,7 @@ impl InkPen {...@@ -603,7 +617,7 @@ impl InkPen {
603 if let Some(operation) = self.raster_operation {617 if let Some(operation) = self.raster_operation {
604 values.push((0x0c003413, vec![operation]));618 values.push((0x0c003413, vec![operation]));
605 }619 }
606 if !self.shape {620 if !self.shape && !self.pressure {
607 values.push((0x88003411, Vec::new()));621 values.push((0x88003411, Vec::new()));
608 }622 }
609 values623 values
...@@ -626,6 +640,14 @@ fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> {...@@ -626,6 +640,14 @@ fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> {
626 "Stroke points and pen size must be finite and positive",640 "Stroke points and pen size must be finite and positive",
627 ));641 ));
628 }642 }
643 if !stroke.pressure.is_empty()
644 && (stroke.pressure.len() != stroke.points.len()
645 || stroke.pressure.iter().any(|p| !p.is_finite()))
646 {
647 return Err(invalid(
648 "A stroke's pressure needs one finite level per point",
649 ));
650 }
629 Ok(vec![(0x1c00340b, stroke.packet())])651 Ok(vec![(0x1c00340b, stroke.packet())])
630}652}
631653
crates/onestore/src/op/lower.rs+11-7
...@@ -328,11 +328,8 @@ pub(crate) fn validate(...@@ -328,11 +328,8 @@ pub(crate) fn validate(
328 }328 }
329 }329 }
330 (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {330 (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {
331 if table.id != previous.id331 if table.id != previous.id || table.layout != previous.layout {
332 || table.layout != previous.layout332 return Err(invalid("Table identity and layout cannot be edited"));
333 || table.tags != previous.tags
334 {
335 return Err(invalid("Table identity, layout and tags cannot be edited"));
336 }333 }
337 let cells: BTreeMap<ExGuid, &TableCell> = previous334 let cells: BTreeMap<ExGuid, &TableCell> = previous
338 .rows335 .rows
...@@ -747,6 +744,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {...@@ -747,6 +744,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {
747 match &mut stripped.content {744 match &mut stripped.content {
748 ParagraphContent::Text(text) => text.tags.clear(),745 ParagraphContent::Text(text) => text.tags.clear(),
749 ParagraphContent::Table(table) => {746 ParagraphContent::Table(table) => {
747 table.tags.clear();
750 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {748 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
751 cell.paragraphs = cell.paragraphs.iter().map(bare).collect();749 cell.paragraphs = cell.paragraphs.iter().map(bare).collect();
752 }750 }
...@@ -1766,8 +1764,14 @@ impl Lowering {...@@ -1766,8 +1764,14 @@ impl Lowering {
1766 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?1764 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?
1767 .clone();1765 .clone();
1768 let mut targets = vec![(*id, &paragraph.tags, current.tags.clone())];1766 let mut targets = vec![(*id, &paragraph.tags, current.tags.clone())];
1769 if let (Some(text), Some(stored)) = (paragraph.text(), current.text()) {1767 match (&paragraph.content, &current.content) {
1770 targets.push((text.id, &text.tags, stored.tags.clone()));1768 (ParagraphContent::Text(text), ParagraphContent::Text(stored)) => {
1769 targets.push((text.id, &text.tags, stored.tags.clone()))
1770 }
1771 (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => {
1772 targets.push((table.id, &table.tags, stored.tags.clone()))
1773 }
1774 _ => {}
1771 }1775 }
1772 for (target, tags, stored) in targets {1776 for (target, tags, stored) in targets {
1773 if same_tags(tags, &stored) {1777 if same_tags(tags, &stored) {
crates/onestore/src/op/model.rs+8-1
...@@ -450,7 +450,8 @@ fn inserted(text: &Paragraph) -> Paragraph {...@@ -450,7 +450,8 @@ fn inserted(text: &Paragraph) -> Paragraph {
450 }))450 }))
451}451}
452452
453/// A stroke as the store keeps it: points on the HIMETRIC grid, pen size in HIMETRIC.453/// A stroke as the store keeps it: points on the HIMETRIC grid, pen size in HIMETRIC,
454/// pressure in levels.
454fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {455fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {
455 let himetric = |points: f32| points * 2540.0 / 72.0 * 72.0 / 2540.0;456 let himetric = |points: f32| points * 2540.0 / 72.0 * 72.0 / 2540.0;
456 crate::page::InkStroke {457 crate::page::InkStroke {
...@@ -461,6 +462,12 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {...@@ -461,6 +462,12 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {
461 .collect(),462 .collect(),
462 width: himetric(stroke.width),463 width: himetric(stroke.width),
463 height: himetric(stroke.height),464 height: himetric(stroke.height),
465 pressure: stroke
466 .pressure
467 .iter()
468 .copied()
469 .map(crate::page::ink::level)
470 .collect(),
464 ..stroke.clone()471 ..stroke.clone()
465 }472 }
466}473}
crates/onestore/src/op/tests.rs+10-1
...@@ -1099,8 +1099,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1099,8 +1099,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1099 layout: Layout::default(),1099 layout: Layout::default(),
1100 size: Some([0.75, 0.75]),1100 size: Some([0.75, 0.75]),
1101 bytes: Some(PNG.into()),1101 bytes: Some(PNG.into()),
1102 display: None,
1102 alt: Some("dot".into()),1103 alt: Some("dot".into()),
1103 background: false,1104 background: false,
1105 printout: None,
1104 }),1106 }),
1105 Family::Attachment => ParagraphContent::Attachment(Attachment {1107 Family::Attachment => ParagraphContent::Attachment(Attachment {
1106 id: id(),1108 id: id(),
...@@ -1152,8 +1154,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1152,8 +1154,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1152 },1154 },
1153 size: Some([0.75, 0.75]),1155 size: Some([0.75, 0.75]),
1154 bytes: Some(PNG.into()),1156 bytes: Some(PNG.into()),
1157 display: None,
1155 alt: None,1158 alt: None,
1156 background: false,1159 background: false,
1160 printout: None,
1157 })1161 })
1158 } else {1162 } else {
1159 PageObject::Ink(Ink {1163 PageObject::Ink(Ink {
...@@ -1254,8 +1258,13 @@ fn stroke(rng: &mut Rng) -> InkStroke {...@@ -1254,8 +1258,13 @@ fn stroke(rng: &mut Rng) -> InkStroke {
1254 color: Some(0x0000ff),1258 color: Some(0x0000ff),
1255 transparency: None,1259 transparency: None,
1256 pen_tip: None,1260 pen_tip: None,
1257 // Every other stroke a highlighter's.1261 // Every other stroke a highlighter's, and every third a pressure pen's.
1258 raster_operation: (x as u32).is_multiple_of(2).then_some(9),1262 raster_operation: (x as u32).is_multiple_of(2).then_some(9),
1263 pressure: if (x as u32).is_multiple_of(3) {
1264 [0.0, 0.3, 1.0].map(crate::page::ink::level).to_vec()
1265 } else {
1266 Vec::new()
1267 },
1259 }1268 }
1260}1269}
12611270
crates/onestore/src/page/ink.rs+75-11
...@@ -23,6 +23,14 @@ const DIMENSION_X: [u8; 16] = [...@@ -23,6 +23,14 @@ const DIMENSION_X: [u8; 16] = [
23const DIMENSION_Y: [u8; 16] = [23const DIMENSION_Y: [u8; 16] = [
24 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11,24 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11,
25];25];
26/// The highest pressure level new strokes store, as a 1024-level pen reports it.
27const PRESSURE_LEVELS: f32 = 1023.0;
28
29/// Rounds a pressure to the level a stroke stores, so a stroke written from it reads back
30/// equal.
31pub fn level(pressure: f32) -> f32 {
32 (pressure.clamp(0.0, 1.0) * PRESSURE_LEVELS).round() / PRESSURE_LEVELS
33}
2634
27#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]35#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
28pub struct Ink {36pub struct Ink {
...@@ -120,6 +128,7 @@ impl Ink {...@@ -120,6 +128,7 @@ impl Ink {
120 strokes.push(InkStroke {128 strokes.push(InkStroke {
121 id: super::text::new_id()?,129 id: super::text::new_id()?,
122 points: path.into_iter().map(|p| p.map(snap)).collect(),130 points: path.into_iter().map(|p| p.map(snap)).collect(),
131 pressure: Vec::new(),
123 ..pen.clone()132 ..pen.clone()
124 });133 });
125 }134 }
...@@ -165,6 +174,10 @@ pub struct InkStroke {...@@ -165,6 +174,10 @@ pub struct InkStroke {
165 /// what lies beneath.174 /// what lies beneath.
166 #[serde(default)]175 #[serde(default)]
167 pub raster_operation: Option<u8>,176 pub raster_operation: Option<u8>,
177 /// The pen's pressure at each point, from none at 0 to full at 1, for a stroke whose
178 /// width follows it; empty where the pen ignores pressure, as a mouse's does.
179 #[serde(default)]
180 pub pressure: Vec<f32>,
168}181}
169182
170impl Ink {183impl Ink {
...@@ -273,6 +286,7 @@ impl InkStroke {...@@ -273,6 +286,7 @@ impl InkStroke {
273 transparency,286 transparency,
274 pen_tip,287 pen_tip,
275 raster_operation,288 raster_operation,
289 ignore_pressure,
276 ..290 ..
277 } = &style.kind291 } = &style.kind
278 else {292 else {
...@@ -281,16 +295,16 @@ impl InkStroke {...@@ -281,16 +295,16 @@ impl InkStroke {
281 if dimensions.len() % 32 != 0 || dimensions.is_empty() {295 if dimensions.len() % 32 != 0 || dimensions.is_empty() {
282 return Err(invalid("Ink dimension table has an invalid length"));296 return Err(invalid("Ink dimension table has an invalid length"));
283 }297 }
284 let guids: Vec<&[u8]> = dimensions.chunks_exact(32).map(|d| &d[..16]).collect();298 let entries: Vec<&[u8]> = dimensions.chunks_exact(32).collect();
285 let axis = |guid: [u8; 16]| guids.iter().position(|g| **g == guid);299 let axis = |guid: &[u8]| entries.iter().position(|entry| entry[..16] == *guid);
286 let (Some(x), Some(y)) = (axis(DIMENSION_X), axis(DIMENSION_Y)) else {300 let (Some(x), Some(y)) = (axis(&DIMENSION_X), axis(&DIMENSION_Y)) else {
287 return Err(invalid("Ink dimension table lacks X and Y"));301 return Err(invalid("Ink dimension table lacks X and Y"));
288 };302 };
289 let values = multi_byte(path).ok_or_else(|| invalid("Ink stroke packet is malformed"))?;303 let values = multi_byte(path).ok_or_else(|| invalid("Ink stroke packet is malformed"))?;
290 if values.len() % guids.len() != 0 {304 if values.len() % entries.len() != 0 {
291 return Err(invalid("Ink stroke packet does not cover its dimensions"));305 return Err(invalid("Ink stroke packet does not cover its dimensions"));
292 }306 }
293 let per_dimension = values.len() / guids.len();307 let per_dimension = values.len() / entries.len();
294 let coordinates = |dimension: usize, factor: f32| {308 let coordinates = |dimension: usize, factor: f32| {
295 let mut position = 0i64;309 let mut position = 0i64;
296 values[dimension * per_dimension..(dimension + 1) * per_dimension]310 values[dimension * per_dimension..(dimension + 1) * per_dimension]
...@@ -304,6 +318,25 @@ impl InkStroke {...@@ -304,6 +318,25 @@ impl InkStroke {
304 .zip(coordinates(y, scale[1]))318 .zip(coordinates(y, scale[1]))
305 .map(|(x, y)| [x, y])319 .map(|(x, y)| [x, y])
306 .collect();320 .collect();
321 let mut pressure = Vec::new();
322 if let Some(dimension) = axis(&PRESSURE_DIMENSION[..16])
323 && *ignore_pressure != Some(true)
324 {
325 // In f32, as `level` divides, so a level written reads back equal.
326 let [lower, upper] = [16, 20].map(|at| {
327 i32::from_le_bytes(entries[dimension][at..at + 4].try_into().unwrap()) as f32
328 });
329 if upper > lower {
330 let mut level = 0i64;
331 pressure = values[dimension * per_dimension..(dimension + 1) * per_dimension]
332 .iter()
333 .map(|delta| {
334 level += delta;
335 (level as f32 - lower) / (upper - lower)
336 })
337 .collect();
338 }
339 }
307 Ok(Self {340 Ok(Self {
308 id,341 id,
309 points,342 points,
...@@ -313,8 +346,18 @@ impl InkStroke {...@@ -313,8 +346,18 @@ impl InkStroke {
313 transparency: *transparency,346 transparency: *transparency,
314 pen_tip: *pen_tip,347 pen_tip: *pen_tip,
315 raster_operation: *raster_operation,348 raster_operation: *raster_operation,
349 pressure,
316 })350 })
317 }351 }
352
353 /// How many times its pen's size the stroke draws at point `index`, as OneNote 2010
354 /// draws pressure (`corpus/ink-pressure`): a quarter at none, all of it at half and 1.75
355 /// times at full.
356 pub fn thickness(&self, index: usize) -> f32 {
357 self.pressure
358 .get(index)
359 .map_or(1.0, |pressure| 0.25 + 1.5 * pressure)
360 }
318}361}
319362
320impl InkShape {363impl InkShape {
...@@ -421,19 +464,32 @@ fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {...@@ -421,19 +464,32 @@ fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {
421}464}
422465
423impl InkStroke {466impl InkStroke {
424 /// The stroke packet OneNote stores: X then Y as ISF multi-byte first differences of the467 /// The stroke packet OneNote stores: X, Y and any pressure as ISF multi-byte first
425 /// points rounded to HIMETRIC.468 /// differences, of the points rounded to HIMETRIC and the pressure's levels.
426 pub(crate) fn packet(&self) -> Vec<u8> {469 pub(crate) fn packet(&self) -> Vec<u8> {
427 let axis = |index: usize| {470 let differences = |values: Vec<i64>| {
428 let mut previous = 0i64;471 let mut previous = 0i64;
429 self.points.iter().map(move |point| {472 values.into_iter().map(move |value| {
430 let value = (point[index] * 2540.0 / 72.0).round() as i64;
431 let delta = value - previous;473 let delta = value - previous;
432 previous = value;474 previous = value;
433 delta475 delta
434 })476 })
435 };477 };
436 let values: Vec<i64> = axis(0).chain(axis(1)).collect();478 let axis = |index: usize| {
479 differences(
480 self.points
481 .iter()
482 .map(|point| (point[index] * 2540.0 / 72.0).round() as i64)
483 .collect(),
484 )
485 };
486 let pressure = differences(
487 self.pressure
488 .iter()
489 .map(|pressure| (pressure.clamp(0.0, 1.0) * PRESSURE_LEVELS).round() as i64)
490 .collect(),
491 );
492 let values: Vec<i64> = axis(0).chain(axis(1)).chain(pressure).collect();
437 let mut out = Vec::new();493 let mut out = Vec::new();
438 let mut push = |raw: u64| {494 let mut push = |raw: u64| {
439 let mut raw = raw;495 let mut raw = raw;
...@@ -464,6 +520,13 @@ pub(crate) const DIMENSIONS: [u8; 64] = [...@@ -464,6 +520,13 @@ pub(crate) const DIMENSIONS: [u8; 64] = [
464 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,520 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,
465];521];
466522
523/// The entry a pressure-sensitive pen adds to `DIMENSIONS`: NormalPressure from 0 to 1023, of
524/// no unit and resolution 1, as OneNote keeps it (`corpus/ink-pressure`).
525pub(crate) const PRESSURE_DIMENSION: [u8; 32] = [
526 0x2d, 0x50, 0x07, 0x73, 0xf4, 0xf9, 0x18, 0x4e, 0xb3, 0xf2, 0x2c, 0xe1, 0xb1, 0xa3, 0x61, 0x0c,
527 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f,
528];
529
467/// The dimension table OneNote 2010 writes for a drawn shape's pen: X and Y in HIMETRIC over530/// The dimension table OneNote 2010 writes for a drawn shape's pen: X and Y in HIMETRIC over
468/// the whole 32-bit range, at a resolution of 1000.531/// the whole 32-bit range, at a resolution of 1000.
469pub(crate) const SHAPE_DIMENSIONS: [u8; 64] = [532pub(crate) const SHAPE_DIMENSIONS: [u8; 64] = [
...@@ -488,6 +551,7 @@ mod tests {...@@ -488,6 +551,7 @@ mod tests {
488 transparency: None,551 transparency: None,
489 pen_tip: None,552 pen_tip: None,
490 raster_operation: None,553 raster_operation: None,
554 pressure: Vec::new(),
491 };555 };
492 let values = multi_byte(&stroke.packet()).unwrap();556 let values = multi_byte(&stroke.packet()).unwrap();
493 assert_eq!(values.len(), 6);557 assert_eq!(values.len(), 6);
crates/onestore/src/page/mod.rs+34-5
...@@ -429,8 +429,16 @@ pub struct Image {...@@ -429,8 +429,16 @@ pub struct Image {
429 pub size: Option<[f32; 2]>,429 pub size: Option<[f32; 2]>,
430 #[serde(with = "payload")]430 #[serde(with = "payload")]
431 pub bytes: Option<Arc<[u8]>>,431 pub bytes: Option<Arc<[u8]>>,
432 /// The raster OneNote shows for a picture stored in a format it draws itself, a
433 /// printout's XPS page or a metafile (WebPictureContainer14).
434 #[serde(default, with = "payload")]
435 pub display: Option<Arc<[u8]>>,
432 pub alt: Option<String>,436 pub alt: Option<String>,
433 pub background: bool,437 pub background: bool,
438 /// The page of a file printout the picture shows, zero-based (DisplayedPageNumber);
439 /// OneNote frames printout pages.
440 #[serde(default)]
441 pub printout: Option<u32>,
434}442}
435443
436impl PartialEq for Image {444impl PartialEq for Image {
...@@ -440,6 +448,7 @@ impl PartialEq for Image {...@@ -440,6 +448,7 @@ impl PartialEq for Image {
440 && self.size == other.size448 && self.size == other.size
441 && self.alt == other.alt449 && self.alt == other.alt
442 && self.background == other.background450 && self.background == other.background
451 && self.printout == other.printout
443 }452 }
444}453}
445454
...@@ -454,6 +463,7 @@ impl Image {...@@ -454,6 +463,7 @@ impl Image {
454 container,463 container,
455 alt,464 alt,
456 background,465 background,
466 printout,
457 picture_width,467 picture_width,
458 picture_height,468 picture_height,
459 ..469 ..
...@@ -461,25 +471,44 @@ impl Image {...@@ -461,25 +471,44 @@ impl Image {
461 else {471 else {
462 unreachable!()472 unreachable!()
463 };473 };
464 let bytes = if let Some(container) = container {474 let payload = |container: Option<&ExGuid>| -> Result<Option<Arc<[u8]>>, Error> {
475 let Some(container) = container else {
476 return Ok(None);
477 };
465 let data = revision478 let data = revision
466 .nodes479 .nodes
467 .get(container)480 .get(container)
468 .ok_or_else(|| invalid("Missing canvas image data"))?;481 .ok_or_else(|| invalid("Missing canvas image data"))?;
469 match &data.kind {482 match &data.kind {
470 Kind::File { payload, .. } => payload.map(Arc::from),483 Kind::File { payload, .. } => Ok(payload.map(Arc::from)),
471 _ => return Err(invalid("Canvas image data has the wrong type")),484 _ => Err(invalid("Canvas image data has the wrong type")),
472 }485 }
473 } else {
474 None
475 };486 };
487 let bytes = payload(container.as_ref())?;
488 let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default();
489 let web = fields.iter().find_map(|field| match &field.value {
490 FieldValue::Objects(ids) if field.id == 0x200034c8 => ids.first(),
491 _ => None,
492 });
476 Ok(Self {493 Ok(Self {
477 id,494 id,
478 layout: node.layout.clone(),495 layout: node.layout.clone(),
479 size: picture_width.zip(*picture_height).map(|(w, h)| [w, h]),496 size: picture_width.zip(*picture_height).map(|(w, h)| [w, h]),
480 bytes,497 bytes,
498 display: payload(web)?,
481 alt: alt.clone(),499 alt: alt.clone(),
482 background: background.unwrap_or(false),500 background: background.unwrap_or(false),
501 printout: printout.unwrap_or(false).then(|| {
502 fields
503 .iter()
504 .find_map(|field| match field.value {
505 FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => {
506 Some(u32::from_le_bytes([a, b, c, d]))
507 }
508 _ => None,
509 })
510 .unwrap_or(0)
511 }),
483 })512 })
484 }513 }
485}514}
crates/onestore/src/pages.rs+10
...@@ -553,6 +553,16 @@ impl PageCreation {...@@ -553,6 +553,16 @@ impl PageCreation {
553 Ok(self)553 Ok(self)
554 }554 }
555555
556 /// Creates the page in the object space `{guid},1`, its series named after it, instead of
557 /// under fresh identities: a merge that recreates a page, or must create the same page
558 /// each time it runs, names it.
559 pub fn in_space(mut self, guid: [u8; 16]) -> Result<Self, Error> {
560 self.guid = guid;
561 self.series_guid = guid.map(|byte| byte ^ 0x5a);
562 self.validate()?;
563 Ok(self)
564 }
565
556 /// Who creates the page.566 /// Who creates the page.
557 pub fn author(&self) -> &str {567 pub fn author(&self) -> &str {
558 &self.author568 &self.author
crates/onestore/src/paragraph.rs+9-2
...@@ -119,7 +119,7 @@ impl ParagraphSplit {...@@ -119,7 +119,7 @@ impl ParagraphSplit {
119 }119 }
120 } else if let Some(after) = runs120 } else if let Some(after) = runs
121 .iter()121 .iter()
122 .position(|run| run.start == self.offset && self.offset > 0)122 .position(|run| run.start == self.offset && run.end > run.start && self.offset > 0)
123 && link(after - 1)123 && link(after - 1)
124 && link(after)124 && link(after)
125 && !resolved[after].text.starts_with('\u{fddf}')125 && !resolved[after].text.starts_with('\u{fddf}')
...@@ -142,7 +142,14 @@ impl ParagraphSplit {...@@ -142,7 +142,14 @@ impl ParagraphSplit {
142 guid: self.guid,142 guid: self.guid,
143 n: 4,143 n: 4,
144 };144 };
145 let typing = runs.last().and_then(|run| run.format).unwrap_or(normal_id);145 // The text's last characters' style, past a final run an edit emptied.
146 let typing = runs
147 .iter()
148 .rev()
149 .find(|run| run.end > run.start)
150 .or(runs.last())
151 .and_then(|run| run.format)
152 .unwrap_or(normal_id);
146 // Enter at the start leaves the emptied upper paragraph the first run's style, as153 // Enter at the start leaves the emptied upper paragraph the first run's style, as
147 // OneNote does (`evidence/structural-edits/xml/c2s-*`).154 // OneNote does (`evidence/structural-edits/xml/c2s-*`).
148 let leading = runs.first().and_then(|run| run.format);155 let leading = runs.first().and_then(|run| run.format);
crates/onestore/src/section.rs+128-58
...@@ -34,8 +34,8 @@ pub struct Section<'a> {...@@ -34,8 +34,8 @@ pub struct Section<'a> {
34 /// The in-place writes of the sealed transactions, in order.34 /// The in-place writes of the sealed transactions, in order.
35 patches: Vec<(u64, Vec<u8>)>,35 patches: Vec<(u64, Vec<u8>)>,
36 state: StoreState,36 state: StoreState,
37 /// Payloads the opened image embeds, by identity.37 /// Payloads the opened images embed, by identity, with the image holding each.
38 files: &'a [([u8; 16], Chunk)],38 files: &'a [([u8; 16], Chunk, &'a [u8])],
39 /// Payload identities the file-data store declares.39 /// Payload identities the file-data store declares.
40 declared: BTreeSet<[u8; 16]>,40 declared: BTreeSet<[u8; 16]>,
41 spaces: BTreeMap<ExGuid, Space<'a>>,41 spaces: BTreeMap<ExGuid, Space<'a>>,
...@@ -139,74 +139,125 @@ struct Open<'a> {...@@ -139,74 +139,125 @@ struct Open<'a> {
139impl<'a> Section<'a> {139impl<'a> Section<'a> {
140 /// Parses and fully validates a section image.140 /// Parses and fully validates a section image.
141 pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> {141 pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> {
142 let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image);142 Self::open_with(arena, vec![image], |_, space| {
143 drop(image);143 space.labels.get(&(ExGuid::default(), 1)).copied()
144 let store = Store::parse(bytes)?;144 })
145 if let Some(offset) = store.checksum_mismatches.first() {145 }
146 return Err(Error {146
147 offset: *offset,147 /// Opens an earlier state of a section: each object space in `revisions` at the revision
148 message: "Cannot write a file with transaction checksum damage",148 /// given, taken from the first of `images` that stores it, and no other space; the first
149 });149 /// image is the section's. For reading, as a merge reads the state two copies of a section
150 }150 /// last shared, which may lie partly in each copy.
151 if store.header.file_type != FileType::Section {151 pub fn open_at(
152 arena: &'a Arena,
153 images: Vec<Vec<u8>>,
154 revisions: &BTreeMap<ExGuid, ExGuid>,
155 ) -> Result<Self> {
156 let section = Self::open_with(arena, images, |id, space| {
157 revisions
158 .get(id)
159 .copied()
160 .filter(|rid| space.revisions.contains_key(rid))
161 })?;
162 if !section.spaces.contains_key(&section.root) {
152 return Err(Error {163 return Err(Error {
153 offset: 0,164 offset: 0,
154 message: "Choose a section file",165 message: "The earlier state names no revision of the section's root",
155 });166 });
156 }167 }
157 let index = RevisionIndex::parse(&store)?;168 Ok(section)
158 index.validate_current()?;169 }
170
171 /// `open`, each object space at the revision `revision` chooses in the first image where it
172 /// chooses one; a space it chooses none for is left out. Payloads are read from the image
173 /// that first declares them.
174 fn open_with(
175 arena: &'a Arena,
176 images: Vec<Vec<u8>>,
177 revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option<ExGuid>,
178 ) -> Result<Self> {
179 let mut opened = None;
159 let mut spaces = BTreeMap::new();180 let mut spaces = BTreeMap::new();
160 for (id, space) in &index.spaces {181 let mut files: Vec<([u8; 16], Chunk, &'a [u8])> = Vec::new();
161 let Some(rid) = space.labels.get(&(ExGuid::default(), 1)).copied() else {182 for image in images {
162 continue;183 let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image);
163 };184 drop(image);
164 let revision = bound(bytes, index.resolve(*id, rid)?);185 let store = Store::parse(bytes)?;
165 let history = space186 if let Some(offset) = store.checksum_mismatches.first() {
166 .labels187 return Err(Error {
167 .get(&(versions::HISTORY, 1))188 offset: *offset,
168 .map(|history| -> Result<History<'a>> {189 message: "Cannot write a file with transaction checksum damage",
169 Ok(History {190 });
170 rid: *history,
171 revision: bound(bytes, index.resolve(*id, *history)?),
172 depth: chain_depth(&index, *id, *history),
173 pending: BTreeMap::new(),
174 })
175 })
176 .transpose()?;
177 let state = SpaceState::Stored {
178 revision,
179 depth: chain_depth(&index, *id, rid),
180 };
181 spaces.insert(*id, Space::new(Some(rid), state, history));
182 }
183 let mut files = Vec::new();
184 for node in store.lists.values().flat_map(|list| &list.nodes) {
185 if node.id != 0x94 || node.freed() {
186 continue;
187 }191 }
188 let (Some(guid), Some(Reference::Data(chunk))) =192 if store.header.file_type != FileType::Section {
189 (node.payload.first_chunk(), node.reference)
190 else {
191 return Err(Error {193 return Err(Error {
192 offset: node.offset,194 offset: 0,
193 message: "File-data object lacks a data reference",195 message: "Choose a section file",
194 });196 });
195 };197 }
196 files.push((*guid, chunk));198 let index = RevisionIndex::parse(&store)?;
199 index.validate_current()?;
200 for (id, space) in &index.spaces {
201 let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else {
202 continue;
203 };
204 let revision = bound(bytes, index.resolve(*id, rid)?);
205 let history = space
206 .labels
207 .get(&(versions::HISTORY, 1))
208 .map(|history| -> Result<History<'a>> {
209 Ok(History {
210 rid: *history,
211 revision: bound(bytes, index.resolve(*id, *history)?),
212 depth: chain_depth(&index, *id, *history),
213 pending: BTreeMap::new(),
214 })
215 })
216 .transpose()?;
217 let state = SpaceState::Stored {
218 revision,
219 depth: chain_depth(&index, *id, rid),
220 };
221 spaces.insert(*id, Space::new(Some(rid), state, history));
222 }
223 let known = files.len();
224 for node in store.lists.values().flat_map(|list| &list.nodes) {
225 if node.id != 0x94 || node.freed() {
226 continue;
227 }
228 let (Some(guid), Some(Reference::Data(chunk))) =
229 (node.payload.first_chunk(), node.reference)
230 else {
231 return Err(Error {
232 offset: node.offset,
233 message: "File-data object lacks a data reference",
234 });
235 };
236 // A payload an earlier image declares is the same bytes: it names them.
237 if !files[..known].iter().any(|(declared, ..)| declared == guid) {
238 files.push((*guid, chunk, bytes));
239 }
240 }
241 if opened.is_none() {
242 opened = Some((bytes, store.state()?, index.root));
243 }
197 }244 }
198 files.sort_unstable_by_key(|(guid, _)| *guid);245 let (bytes, state, root) = opened.ok_or(Error {
246 offset: 0,
247 message: "Choose a section file",
248 })?;
249 files.sort_by_key(|(guid, ..)| *guid);
199 Ok(Self {250 Ok(Self {
200 arena,251 arena,
201 segments: vec![(0, bytes)],252 segments: vec![(0, bytes)],
202 patches: Vec::new(),253 patches: Vec::new(),
203 state: store.state()?,254 state,
204 declared: files.iter().map(|(guid, _)| *guid).collect(),255 declared: files.iter().map(|(guid, ..)| *guid).collect(),
205 files: arena.0.alloc_slice_copy(&files),256 files: arena.0.alloc_slice_copy(&files),
206 spaces,257 spaces,
207 payloads: Vec::new(),258 payloads: Vec::new(),
208 broken: false,259 broken: false,
209 root: index.root,260 root,
210 undo: None,261 undo: None,
211 })262 })
212 }263 }
...@@ -426,12 +477,12 @@ impl<'a> Section<'a> {...@@ -426,12 +477,12 @@ impl<'a> Section<'a> {
426477
427 /// Reads a payload of the opened image by identity.478 /// Reads a payload of the opened image by identity.
428 fn files(&self) -> Files<'a> {479 fn files(&self) -> Files<'a> {
429 let (bytes, files) = (self.segments[0].1, self.files);480 let files = self.files;
430 std::rc::Rc::new(move |guid| {481 std::rc::Rc::new(move |guid| {
431 let start = files.partition_point(|(id, _)| *id < guid);482 let start = files.partition_point(|(id, ..)| *id < guid);
432 match files[start..]483 match files[start..]
433 .iter()484 .iter()
434 .take_while(|(id, _)| *id == guid)485 .take_while(|(id, ..)| *id == guid)
435 .count()486 .count()
436 {487 {
437 0 => Err(Error {488 0 => Err(Error {
...@@ -439,7 +490,7 @@ impl<'a> Section<'a> {...@@ -439,7 +490,7 @@ impl<'a> Section<'a> {
439 message: "File-data object is not declared",490 message: "File-data object is not declared",
440 }),491 }),
441 1 => {492 1 => {
442 let chunk = files[start].1;493 let (_, chunk, bytes) = files[start];
443 let (offset, length) = (chunk.offset as usize, chunk.length as usize);494 let (offset, length) = (chunk.offset as usize, chunk.length as usize);
444 let blob = bytes.get(offset..offset + length).ok_or(Error {495 let blob = bytes.get(offset..offset + length).ok_or(Error {
445 offset,496 offset,
...@@ -633,7 +684,26 @@ impl<'a> Section<'a> {...@@ -633,7 +684,26 @@ impl<'a> Section<'a> {
633 /// embed, as one transaction on `stamp`; none when nothing changed. An error leaves the684 /// embed, as one transaction on `stamp`; none when nothing changed. An error leaves the
634 /// section to be reopened.685 /// section to be reopened.
635 pub fn seal(&mut self) -> Result<Option<Transaction>> {686 pub fn seal(&mut self) -> Result<Option<Transaction>> {
687 self.seal_as(&BTreeMap::new())
688 }
689
690 /// `seal`, each space in `names` taking the revision identity named there, one the space
691 /// does not store, instead of a fresh one: a merge names what it wrote after what it
692 /// merged, so that the next merge knows it holds that.
693 pub fn seal_as(&mut self, names: &BTreeMap<ExGuid, ExGuid>) -> Result<Option<Transaction>> {
636 self.usable()?;694 self.usable()?;
695 if names.iter().any(|(space, name)| {
696 name.guid == [0; 16]
697 || self
698 .spaces
699 .get(space)
700 .is_some_and(|stored| stored.rid == Some(*name))
701 }) {
702 return Err(Error {
703 offset: 0,
704 message: "Name each revision anew",
705 });
706 }
637 self.broken = true;707 self.broken = true;
638 let arena = &self.arena.0;708 let arena = &self.arena.0;
639 let mut appending = Appending::new(self.state.clone());709 let mut appending = Appending::new(self.state.clone());
...@@ -680,7 +750,7 @@ impl<'a> Section<'a> {...@@ -680,7 +750,7 @@ impl<'a> Section<'a> {
680 previous: space.restored.or(space.rid),750 previous: space.restored.or(space.rid),
681 new_space: space.rid.is_none(),751 new_space: space.rid.is_none(),
682 label: (ExGuid::default(), 1),752 label: (ExGuid::default(), 1),
683 rid: None,753 rid: names.get(id).copied(),
684 live: file,754 live: file,
685 commit: &commit,755 commit: &commit,
686 replaced: &replaced,756 replaced: &replaced,
crates/onestore/src/section/versions.rs+10-3
...@@ -134,9 +134,16 @@ impl<'a> Section<'a> {...@@ -134,9 +134,16 @@ impl<'a> Section<'a> {
134 }134 }
135 let image = self.image();135 let image = self.image();
136 let store = Store::parse(&image)?;136 let store = Store::parse(&image)?;
137 let index = RevisionIndex::parse(&store)?;137 let rid = self.version_revision(&RevisionIndex::parse(&store)?, space, context)?;
138 let rid = self.version_revision(&index, space, context)?;138 self.page_at(space, rid)
139 let resolved = index.resolve(space, rid)?;139 }
140
141 /// Page `space` as its stored revision `revision` holds it, whichever context that
142 /// revision is current under, if any. Reads the sealed image: O(section).
143 pub fn page_at(&self, space: ExGuid, revision: ExGuid) -> Result<Page> {
144 let image = self.image();
145 let store = Store::parse(&image)?;
146 let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?;
140 let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| {147 let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| {
141 store.file_data(guid)148 store.file_data(guid)
142 })?;149 })?;
crates/onestore/src/snapshot.rs+25-17
...@@ -23,29 +23,47 @@ pub(crate) fn read_exact(...@@ -23,29 +23,47 @@ pub(crate) fn read_exact(
2323
24/// Reads a bounded snapshot; the caller must provide fresh I/O and exclude in-place maintenance.24/// Reads a bounded snapshot; the caller must provide fresh I/O and exclude in-place maintenance.
25/// Includes unpublished trailing bytes so subsequent commits can validate the physical file.25/// Includes unpublished trailing bytes so subsequent commits can validate the physical file.
26/// `None` is a torn read or storage that does not validate.
26pub fn read_snapshot(27pub fn read_snapshot(
27 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,28 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
28 limit: usize,29 limit: usize,
29) -> io::Result<Option<Vec<u8>>> {30) -> io::Result<Option<Vec<u8>>> {
30 snapshot(read, limit, |store| {31 let Some(bytes) = image(read, limit)? else {
31 RevisionIndex::parse(store)?.validate_current()32 return Ok(None);
32 })33 };
34 let valid = Store::parse(&bytes).is_ok_and(|store| {
35 store.checksum_mismatches.is_empty()
36 && RevisionIndex::parse(&store).is_ok_and(|index| index.validate_current().is_ok())
37 });
38 Ok(valid.then_some(bytes))
33}39}
3440
35/// Reads stable storage and checksums without requiring traversable property references.41/// Reads stable storage and checksums without requiring traversable property references.
36/// Used to inspect encrypted or incomplete documents; this does not establish edit readiness.42/// Used to inspect encrypted or incomplete documents; this does not establish edit readiness.
37/// The caller must provide fresh I/O and exclude in-place maintenance, as for `read_snapshot`.43/// The caller must provide fresh I/O and exclude in-place maintenance, as for `read_snapshot`.
44/// `None` is a torn read; stable storage that fails to parse or checksum is `InvalidData`.
38pub fn read_storage_snapshot(45pub fn read_storage_snapshot(
39 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,46 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
40 limit: usize,47 limit: usize,
41) -> io::Result<Option<Vec<u8>>> {48) -> io::Result<Option<Vec<u8>>> {
42 snapshot(read, limit, |_| Ok(()))49 let Some(bytes) = image(read, limit)? else {
50 return Ok(None);
51 };
52 let store =
53 Store::parse(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
54 if !store.checksum_mismatches.is_empty() {
55 return Err(io::Error::new(
56 io::ErrorKind::InvalidData,
57 "Checksum mismatch",
58 ));
59 }
60 Ok(Some(bytes))
43}61}
4462
45fn snapshot(63/// The file's bytes, or `None` where its header changed or its storage ended early meanwhile.
64fn image(
46 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,65 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
47 limit: usize,66 limit: usize,
48 validate: impl FnOnce(&Store<'_>) -> Result<(), crate::Error>,
49) -> io::Result<Option<Vec<u8>>> {67) -> io::Result<Option<Vec<u8>>> {
50 let mut header = [0; 1024];68 let mut header = [0; 1024];
51 read_exact(&mut read, 0, &mut header)?;69 read_exact(&mut read, 0, &mut header)?;
...@@ -82,15 +100,5 @@ fn snapshot(...@@ -82,15 +100,5 @@ fn snapshot(
82 }100 }
83 let mut after = [0; 1024];101 let mut after = [0; 1024];
84 read_exact(&mut read, 0, &mut after)?;102 read_exact(&mut read, 0, &mut after)?;
85 if header != after {103 Ok((header == after).then_some(bytes))
86 return Ok(None);
87 }
88 let parsed = Store::parse(&bytes).and_then(|store| {
89 if !store.checksum_mismatches.is_empty() {
90 return Ok(false);
91 }
92 validate(&store)?;
93 Ok(true)
94 });
95 Ok(matches!(parsed, Ok(true)).then_some(bytes))
96}104}
crates/onestore/tests/history.rs created+181
...@@ -0,0 +1,181 @@
1//! Earlier states of a section: a page as one of its stored revisions holds it, the section
2//! as some revisions of its spaces leave it, and revisions sealed under chosen names.
3
4use onestore::{
5 Arena, ExGuid, PageCreation, RevisionIndex, Section, Store,
6 op::{Edit, Op, PageOp, SectionOp},
7};
8use std::collections::BTreeMap;
9
10fn read(path: &str) -> Vec<u8> {
11 std::fs::read(format!(
12 "{}/../../corpus/{path}",
13 env!("CARGO_MANIFEST_DIR")
14 ))
15 .unwrap()
16}
17
18/// The active revision of each object space.
19fn active(image: &[u8]) -> BTreeMap<ExGuid, ExGuid> {
20 let store = Store::parse(image).unwrap();
21 let index = RevisionIndex::parse(&store).unwrap();
22 index
23 .spaces
24 .keys()
25 .filter_map(|space| Some((*space, index.active(*space).ok()?)))
26 .collect()
27}
28
29/// Two OneNote 2010 clients' captures of one notebook, earliest first: each later file holds
30/// every revision the earlier ones made current.
31const STAGES: [&[&str]; 3] = [
32 &[
33 "conflict-page/native/initial/notebook/synthetic.one",
34 "conflict-page/native/b-published/notebook/synthetic.one",
35 "conflict-page/native/conflict/notebook/synthetic.one",
36 ],
37 &[
38 "conflict-page/native-pages/initial/notebook/synthetic.one",
39 "conflict-page/native-pages/b-published/notebook/synthetic.one",
40 "conflict-page/native-pages/merged/notebook/synthetic.one",
41 ],
42 &[
43 "conflict-page/native-restore/initial/notebook/synthetic.one",
44 "conflict-page/native-restore/b-published/notebook/synthetic.one",
45 "conflict-page/native-restore/merged/notebook/synthetic.one",
46 ],
47];
48
49#[test]
50fn each_stored_revision_of_a_page_reads_as_onenote_showed_it_then() {
51 let mut compared = 0;
52 for stages in STAGES {
53 let last = read(stages[stages.len() - 1]);
54 let arena = Arena::default();
55 let history = Section::open(&arena, last.clone()).unwrap();
56 for earlier in &stages[..stages.len() - 1] {
57 let earlier = read(earlier);
58 let arena = Arena::default();
59 let mut then = Section::open(&arena, earlier.clone()).unwrap();
60 let revisions = active(&earlier);
61 for (space, ..) in then.pages().unwrap() {
62 assert_eq!(
63 history.page_at(space, revisions[&space]).unwrap(),
64 then.page(space).unwrap(),
65 "{space} at {}",
66 revisions[&space]
67 );
68 compared += 1;
69 }
70 // The section as those revisions leave it is the earlier file's.
71 let arena = Arena::default();
72 let mut state = Section::open_at(&arena, vec![last.clone()], &revisions).unwrap();
73 assert_eq!(state.pages().unwrap(), then.pages().unwrap());
74 assert_eq!(state.series().unwrap(), then.series().unwrap());
75 for (space, ..) in then.pages().unwrap() {
76 assert_eq!(state.page(space).unwrap(), then.page(space).unwrap());
77 }
78 }
79 }
80 assert!(compared >= 10, "{compared}");
81}
82
83#[test]
84fn an_earlier_state_needs_its_page_list() {
85 let image = read("conflict-page/native/conflict/notebook/synthetic.one");
86 let mut revisions = active(&image);
87 let arena = Arena::default();
88 let root = Section::open(&arena, image.clone()).unwrap().root();
89 revisions.remove(&root);
90 assert!(Section::open_at(&Arena::default(), vec![image], &revisions).is_err());
91}
92
93#[test]
94fn a_sealed_revision_takes_the_name_given_it() {
95 let image = read("conflict-page/native/initial/notebook/synthetic.one");
96 let arena = Arena::default();
97 let mut section = Section::open(&arena, image).unwrap();
98 let (space, ..) = section.pages().unwrap()[0].clone();
99 let body = section
100 .page(space)
101 .unwrap()
102 .objects
103 .iter()
104 .find_map(|object| match object {
105 onestore::page::PageObject::Outline(outline) => outline
106 .paragraphs
107 .iter()
108 .find_map(|paragraph| paragraph.text().map(|text| text.id)),
109 _ => None,
110 })
111 .unwrap();
112 let current = section.revisions().find(|(id, _)| *id == space).unwrap().1;
113 let type_body = |section: &mut Section<'_>| {
114 section
115 .apply(
116 "Author",
117 &Edit {
118 at: 134_030_000_000_000_000,
119 ops: vec![Op::Page {
120 space,
121 op: PageOp::Text {
122 text: body,
123 range: 0..0,
124 with: "Named ".into(),
125 },
126 }],
127 },
128 )
129 .unwrap()
130 };
131 type_body(&mut section);
132 assert!(
133 section
134 .seal_as(&BTreeMap::from([(space, current)]))
135 .is_err()
136 );
137 let arena = Arena::default();
138 let image = read("conflict-page/native/initial/notebook/synthetic.one");
139 let mut section = Section::open(&arena, image).unwrap();
140 type_body(&mut section);
141 let creation = PageCreation::new(None, Some("Also named"), "Author")
142 .unwrap()
143 .in_space([7; 16])
144 .unwrap();
145 let created = creation.space();
146 section
147 .apply(
148 "Author",
149 &Edit {
150 at: 134_030_000_000_000_000,
151 ops: vec![Op::Section(SectionOp::Create(creation))],
152 },
153 )
154 .unwrap();
155 let name = |n| ExGuid {
156 guid: [n; 16],
157 n: 1,
158 };
159 section
160 .seal_as(&BTreeMap::from([(space, name(1)), (created, name(2))]))
161 .unwrap()
162 .unwrap();
163 let sealed = section.image();
164 let revisions = active(&sealed);
165 assert_eq!(revisions[&space], name(1));
166 assert_eq!(revisions[&created], name(2));
167 assert_eq!(
168 created,
169 ExGuid {
170 guid: [7; 16],
171 n: 1
172 }
173 );
174 // The earlier revision still reads as it was.
175 let arena = Arena::default();
176 let reopened = Section::open(&arena, sealed).unwrap();
177 let typed =
178 |page: onestore::page::Page| serde_json::to_string(&page).unwrap().contains("Named ");
179 assert!(!typed(reopened.page_at(space, current).unwrap()));
180 assert!(typed(reopened.page(space).unwrap()));
181}
crates/onestore/tests/page_images.rs+6
...@@ -115,8 +115,10 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {...@@ -115,8 +115,10 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {
115 layout: Default::default(),115 layout: Default::default(),
116 size: Some([0.75, 0.75]),116 size: Some([0.75, 0.75]),
117 bytes: Some(Arc::from(PNG)),117 bytes: Some(Arc::from(PNG)),
118 display: None,
118 alt: None,119 alt: None,
119 background: false,120 background: false,
121 printout: None,
120 };122 };
121 holder.content = ParagraphContent::Image(image.clone());123 holder.content = ParagraphContent::Image(image.clone());
122 body_paragraphs(&mut after).push(holder.clone());124 body_paragraphs(&mut after).push(holder.clone());
...@@ -293,8 +295,10 @@ fn a_page_level_picture_is_inserted_moved_and_removed() {...@@ -293,8 +295,10 @@ fn a_page_level_picture_is_inserted_moved_and_removed() {
293 },295 },
294 size: Some([0.75, 0.75]),296 size: Some([0.75, 0.75]),
295 bytes: Some(Arc::from(PNG)),297 bytes: Some(Arc::from(PNG)),
298 display: None,
296 alt: Some("Placed in Rust".into()),299 alt: Some("Placed in Rust".into()),
297 background: false,300 background: false,
301 printout: None,
298 };302 };
299 after.objects.push(PageObject::Image(image.clone()));303 after.objects.push(PageObject::Image(image.clone()));
300 let written = ops::saved(&source, space, &after).unwrap();304 let written = ops::saved(&source, space, &after).unwrap();
...@@ -358,8 +362,10 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {...@@ -358,8 +362,10 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {
358 layout: Default::default(),362 layout: Default::default(),
359 size: None,363 size: None,
360 bytes: Some(Arc::from(b"not a picture".as_slice())),364 bytes: Some(Arc::from(b"not a picture".as_slice())),
365 display: None,
361 alt: None,366 alt: None,
362 background: false,367 background: false,
368 printout: None,
363 });369 });
364 body_paragraphs(&mut after).push(holder);370 body_paragraphs(&mut after).push(holder);
365 assert!(ops::saved(&source, space, &after).is_err());371 assert!(ops::saved(&source, space, &after).is_err());
crates/onestore/tests/page_ink.rs+163
...@@ -89,6 +89,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {...@@ -89,6 +89,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {
89 transparency: None,89 transparency: None,
90 pen_tip: None,90 pen_tip: None,
91 raster_operation: None,91 raster_operation: None,
92 pressure: Vec::new(),
92 }93 }
93}94}
9495
...@@ -391,6 +392,7 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {...@@ -391,6 +392,7 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
391 transparency: None,392 transparency: None,
392 pen_tip: None,393 pen_tip: None,
393 raster_operation: None,394 raster_operation: None,
395 pressure: Vec::new(),
394 };396 };
395 let marker = InkStroke {397 let marker = InkStroke {
396 width: himetric(70.0),398 width: himetric(70.0),
...@@ -631,3 +633,164 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() {...@@ -631,3 +633,164 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() {
631 }633 }
632 export("ONESTORE_INK_TOOLS_EDIT_EXPORT", "ink.one", &written);634 export("ONESTORE_INK_TOOLS_EDIT_EXPORT", "ink.one", &written);
633}635}
636
637/// OneNote 2010's pressure ink, taken in from ISF (`tools/native/ink-pressure.ps1`).
638const PRESSURE: &[u8] = include_bytes!("../../../corpus/ink-pressure/native/notebook/Pressure.one");
639
640#[test]
641fn onenote_pressure_ink_reads_each_point_s_level_over_the_pen_s_range() {
642 let (_, page) = titled(PRESSURE, "Levels");
643 let [fine, coarse] = drawings(&page)[..] else {
644 panic!("{:?}", drawings(&page).len());
645 };
646 for (ink, range) in [(fine, 1023.0), (coarse, 255.0)] {
647 assert_eq!(ink.strokes.len(), 9);
648 for (step, stroke) in ink.strokes.iter().enumerate() {
649 let level = (step as f32 / 8.0 * range).round() / range;
650 assert_eq!(stroke.pressure.len(), stroke.points.len());
651 assert!(stroke.pressure.iter().all(|p| (p - level).abs() < 1e-6));
652 assert!((stroke.thickness(0) - (0.25 + 1.5 * level)).abs() < 1e-6);
653 }
654 }
655 let (_, page) = titled(PRESSURE, "Strokes");
656 let [ramp, _, flat, tilt, block] = drawings(&page)[..] else {
657 panic!("{:?}", drawings(&page).len());
658 };
659 let ramp = &ramp.strokes[0].pressure;
660 assert_eq!((ramp.len(), ramp[0], ramp[40]), (41, 0.0, 1.0));
661 // A pen that ignores the pressure it recorded draws at its width throughout.
662 assert!(flat.strokes[0].pressure.is_empty());
663 assert_eq!(flat.strokes[0].thickness(20), 1.0);
664 // Tilt beside the pressure leaves the pressure as it is.
665 let tilted = &tilt.strokes[0].pressure;
666 assert!((tilted[0] - 205.0 / 1023.0).abs() < 1e-6, "{tilted:?}");
667 assert_eq!(block.strokes[0].pen_tip, Some(1));
668 assert_eq!(block.strokes[0].pressure.len(), 11);
669}
670
671/// `ONESTORE_INK_PRESSURE_EXPORT` names a new directory receiving the candidate for a cold
672/// reopen: OneNote's pressure ink after Snowbound erases a stroke of one drawing, moves one
673/// and deletes another, beside Snowbound's own pressure strokes.
674#[test]
675fn pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits() {
676 use onestore::document::Kind;
677 use onestore::{OutlineEdit, op::PageOp};
678 let (levels_space, levels) = titled(PRESSURE, "Levels");
679 let fine = drawings(&levels)[0].clone();
680 let erased = ops::page_edited(
681 PRESSURE,
682 levels_space,
683 vec![PageOp::Strokes {
684 ink: fine.id,
685 add: Vec::new(),
686 remove: vec![fine.strokes[0].id],
687 }],
688 )
689 .unwrap();
690 let pen = |points: Vec<[f32; 2]>, pressure: Vec<f32>| Ink {
691 id: new_id().unwrap(),
692 layout: Default::default(),
693 strokes: vec![InkStroke {
694 id: new_id().unwrap(),
695 points: points.into_iter().map(|p| p.map(snap)).collect(),
696 width: snap(himetric(500.0)),
697 height: snap(himetric(500.0)),
698 color: Some(0x7a9a1f),
699 transparency: None,
700 pen_tip: None,
701 raster_operation: None,
702 pressure: pressure
703 .into_iter()
704 .map(onestore::page::ink::level)
705 .collect(),
706 }],
707 groups: Vec::new(),
708 shape: None,
709 };
710 // Constant levels of none, half and full pressure, and a ramp from none to full.
711 let mut inks: Vec<Ink> = [0.0, 0.5, 1.0]
712 .into_iter()
713 .enumerate()
714 .map(|(row, pressure)| {
715 let y = 520.0 + 36.0 * row as f32;
716 pen(vec![[36.0, y], [108.0, y], [180.0, y]], vec![pressure; 3])
717 })
718 .collect();
719 inks.push(pen(
720 (0..=40)
721 .map(|step| [36.0 + 6.0 * step as f32, 640.0])
722 .collect(),
723 (0..=40).map(|step| step as f32 / 40.0).collect(),
724 ));
725 let written = added(&erased, levels_space, &inks);
726 let (strokes_space, strokes) = titled(&written, "Strokes");
727 let [ramp, wave, ..] = drawings(&strokes)[..] else {
728 unreachable!()
729 };
730 let written = ops::page_edited(
731 &written,
732 strokes_space,
733 vec![
734 PageOp::Delete { object: wave.id },
735 PageOp::Outline {
736 object: ramp.id,
737 edit: OutlineEdit::Position { x: 72.0, y: 108.0 },
738 },
739 ],
740 )
741 .unwrap();
742
743 let stored = page_in(&written, levels_space);
744 let found = drawings(&stored);
745 let kept = found.iter().find(|ink| ink.id == fine.id).unwrap();
746 assert_eq!(kept.strokes, fine.strokes[1..]);
747 for ink in &inks {
748 assert_eq!(found.iter().find(|found| found.id == ink.id), Some(&ink));
749 }
750 let stored = page_in(&written, strokes_space);
751 let moved = drawings(&stored);
752 assert_eq!(moved.len(), 4);
753 assert_eq!(
754 (moved[0].layout.x, moved[0].layout.y),
755 (Some(72.0), Some(108.0))
756 );
757 assert_eq!(moved[0].strokes, ramp.strokes);
758
759 // New pressure strokes store NormalPressure after X and Y and leave IgnorePressure out,
760 // as OneNote does with pressure sensitivity on; OneNote's tilt stays as it stored it.
761 let store = Store::parse(&written).unwrap();
762 let index = RevisionIndex::parse(&store).unwrap();
763 let document = Document::parse(&index).unwrap();
764 let style = |space: ExGuid, stroke: ExGuid| {
765 let revision = document.active(space).unwrap();
766 let Kind::InkStroke {
767 style: Some(style), ..
768 } = revision.nodes[&stroke].kind
769 else {
770 panic!()
771 };
772 revision.nodes[&style].kind.clone()
773 };
774 match style(levels_space, inks[0].strokes[0].id) {
775 Kind::InkStyle {
776 dimensions,
777 ignore_pressure: None,
778 ..
779 } => {
780 assert_eq!(dimensions.len(), 96);
781 assert_eq!(
782 &dimensions[64..],
783 &[
784 0x2d, 0x50, 0x07, 0x73, 0xf4, 0xf9, 0x18, 0x4e, 0xb3, 0xf2, 0x2c, 0xe1, 0xb1,
785 0xa3, 0x61, 0x0c, 0, 0, 0, 0, 0xff, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0x80, 0x3f
786 ]
787 );
788 }
789 other => panic!("{other:?}"),
790 }
791 match style(strokes_space, moved[2].strokes[0].id) {
792 Kind::InkStyle { dimensions, .. } => assert_eq!(dimensions.len(), 5 * 32),
793 other => panic!("{other:?}"),
794 }
795 export("ONESTORE_INK_PRESSURE_EXPORT", "Pressure.one", &written);
796}
crates/onestore/tests/shared_snapshot.rs+4-3
...@@ -83,7 +83,7 @@ fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness()...@@ -83,7 +83,7 @@ fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness()
83 let mut broken = source.clone();83 let mut broken = source.clone();
84 let offset = usize::try_from(Store::parse(&source).unwrap().header.root.offset).unwrap();84 let offset = usize::try_from(Store::parse(&source).unwrap().header.root.offset).unwrap();
85 broken[offset] ^= 1;85 broken[offset] ^= 1;
86 assert!(86 assert_eq!(
87 onestore::read_storage_snapshot(87 onestore::read_storage_snapshot(
88 |offset, output| {88 |offset, output| {
89 let offset = usize::try_from(offset).unwrap();89 let offset = usize::try_from(offset).unwrap();
...@@ -93,8 +93,9 @@ fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness()...@@ -93,8 +93,9 @@ fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness()
93 },93 },
94 broken.len()94 broken.len()
95 )95 )
96 .unwrap()96 .unwrap_err()
97 .is_none()97 .kind(),
98 std::io::ErrorKind::InvalidData
98 );99 );
99 }100 }
100}101}
crates/snowbound/Cargo.toml+10-1
...@@ -26,13 +26,18 @@ parley.workspace = true...@@ -26,13 +26,18 @@ parley.workspace = true
26winit = "0.30.13"26winit = "0.30.13"
27arboard = { version = "3.6.1", default-features = false }27arboard = { version = "3.6.1", default-features = false }
28accesskit = "0.25.0"28accesskit = "0.25.0"
29accesskit_consumer = "0.39.0"
29accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }30accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
31# Updates: fetched over HTTPS, verified with ed25519 and SHA-256.
32ureq = { version = "3.4", default-features = false, features = ["rustls"] }
33ring = "0.17"
3034
31[target.'cfg(target_os = "macos")'.dependencies]35[target.'cfg(target_os = "macos")'.dependencies]
32libc = "0.2"36libc = "0.2"
33objc2 = "0.5.2"37objc2 = "0.5.2"
34objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] }38objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] }
35objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }39objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] }
40block2 = "0.5.1"
3641
37[target.'cfg(target_os = "linux")'.dependencies]42[target.'cfg(target_os = "linux")'.dependencies]
38accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }43accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }
...@@ -43,3 +48,7 @@ libc = "0.2"...@@ -43,3 +48,7 @@ libc = "0.2"
43zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }48zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }
44# The colours of winit's own window frame, which the toolbar continues.49# The colours of winit's own window frame, which the toolbar continues.
45sctk-adwaita = { version = "0.10.1", default-features = false }50sctk-adwaita = { version = "0.10.1", default-features = false }
51# Sets the window's icon through xdg-toplevel-icon on winit's own Wayland connection.
52wayland-backend = { version = "0.3.17", features = ["client_system"] }
53wayland-client = "0.31.15"
54wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
crates/snowbound/assets/icons/folder.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="#9fd2ff"/><stop offset="1" stop-color="#3b8ff0"/></linearGradient>
4 </defs>
5 <path d="M1.5 13.75V4.25A1 1 0 0 1 2.5 3.25H6L7.5 4.75H13.5A1 1 0 0 1 14.5 5.75V13.75Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M1.5 13V3.5A1 1 0 0 1 2.5 2.5H6L7.5 4H13.5A1 1 0 0 1 14.5 5V13A0.5 0.5 0 0 1 14 13.5H2A0.5 0.5 0 0 1 1.5 13Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linejoin="round"/>
7 <path d="M2.5 12.5V6.2A0.7 0.7 0 0 1 3.2 5.5H13.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
8</svg>
crates/snowbound/assets/icons/icloud.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="#9fd2ff"/><stop offset="1" stop-color="#3b8ff0"/></linearGradient>
4 </defs>
5 <path d="M4 13.75H11.75A3 3 0 0 0 12.2 7.8A4.3 4.3 0 0 0 4 6.9A3.45 3.45 0 0 0 4 13.75Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M4 13H11.75A3 3 0 0 0 12.2 7.05A4.3 4.3 0 0 0 4 6.15A3.45 3.45 0 0 0 4 13Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linejoin="round"/>
7 <path d="M3.1 9.4A2.2 2.2 0 0 1 5.2 7.4M6.2 5.9A3 3 0 0 1 10.6 6.1" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.85"/>
8</svg>
crates/snowbound/assets/icons/server.svg created+14
...@@ -0,0 +1,14 @@
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="#f4f6fa"/><stop offset="1" stop-color="#aeb8c6"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 </defs>
6 <path d="M3 2.25H13A1 1 0 0 1 14 3.25V10.75A1 1 0 0 1 13 11.75H3A1 1 0 0 1 2 10.75V3.25A1 1 0 0 1 3 2.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M3 1.5H13A1 1 0 0 1 14 2.5V10A1 1 0 0 1 13 11H3A1 1 0 0 1 2 10V2.5A1 1 0 0 1 3 1.5Z" fill="url(#g0)" stroke="#6b7686" stroke-width="0.8" stroke-linejoin="round"/>
8 <path d="M2.4 6.25H13.6" fill="none" stroke="#6b7686" stroke-width="0.8"/>
9 <path d="M4.5 4H9M4.5 8.75H9" fill="none" stroke="#3b4350" stroke-width="1.1" stroke-linecap="round"/>
10 <path d="M11.75 4H11.8M11.75 8.75H11.8" fill="none" stroke="#28b04a" stroke-width="1.6" stroke-linecap="round"/>
11 <path d="M3 3V9.9" fill="none" stroke="#ffffff" stroke-width="0.9" stroke-linecap="round" stroke-opacity="0.85"/>
12 <path d="M8 11.2V13.5M2.5 14.25H13.5" fill="none" stroke="url(#g1)" stroke-width="1.4" stroke-linecap="round"/>
13 <path d="M6.75 13.5H9.25V15H6.75Z" fill="#1f7cf0"/>
14</svg>
crates/snowbound/assets/snow-leopard/buttons-key.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/buttons-key.png differ
crates/snowbound/assets/snow-leopard/buttons-other.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/buttons-other.png differ
crates/snowbound/assets/snow-leopard/scroller-horizontal-key.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-horizontal-key.png differ
crates/snowbound/assets/snow-leopard/scroller-horizontal-other.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-horizontal-other.png differ
crates/snowbound/assets/snow-leopard/scroller-horizontal-track-key.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-horizontal-track-key.png differ
crates/snowbound/assets/snow-leopard/scroller-horizontal-track-other.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-horizontal-track-other.png differ
crates/snowbound/assets/snow-leopard/scroller-vertical-key.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-vertical-key.png differ
crates/snowbound/assets/snow-leopard/scroller-vertical-other.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-vertical-other.png differ
crates/snowbound/assets/snow-leopard/scroller-vertical-track-key.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-vertical-track-key.png differ
crates/snowbound/assets/snow-leopard/scroller-vertical-track-other.png created
Binary files /dev/null and b/crates/snowbound/assets/snow-leopard/scroller-vertical-track-other.png differ
crates/snowbound/assets/tags/bug.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="4" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff7060"/><stop offset="1" stop-color="#c01810"/></linearGradient>
4 </defs>
5 <path d="M3.2 6.4L1 5M3 9.4H0.8M3.4 12.2L1.2 13.8M12.8 6.4L15 5M13 9.4H15.2M12.6 12.2L14.8 13.8M6.4 3.2L5 1M9.6 3.2L11 1" stroke="#301008" stroke-width="0.9" stroke-linecap="round"/>
6 <circle cx="8" cy="4.6" r="2.4" fill="#302020" stroke="#100808" stroke-width="0.5"/>
7 <ellipse cx="8" cy="10" rx="5.2" ry="5.4" fill="url(#g0)" stroke="#6a0804" stroke-width="0.6"/>
8 <path d="M8 4.8V15.2" stroke="#6a0804" stroke-width="0.7"/>
9 <circle cx="5.6" cy="9" r="1" fill="#301008"/>
10 <circle cx="10.4" cy="9" r="1" fill="#301008"/>
11 <circle cx="6" cy="12.4" r="0.8" fill="#301008"/>
12 <circle cx="10" cy="12.4" r="0.8" fill="#301008"/>
13 <ellipse cx="5.8" cy="6.8" rx="1.6" ry="0.8" fill="#ffffff" fill-opacity="0.5"/>
14</svg>
crates/snowbound/assets/tags/camera.svg created+12
...@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="3" x2="0" y2="14" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8a929c"/><stop offset="1" stop-color="#3a4048"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="5" x2="0" y2="13" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9ad0ff"/><stop offset="1" stop-color="#1a4a90"/></linearGradient>
5 </defs>
6 <path d="M5.2 2.6H10.8L11.8 4.4H14C14.8 4.4 15.4 5 15.4 5.8V12.6C15.4 13.4 14.8 14 14 14H2C1.2 14 0.6 13.4 0.6 12.6V5.8C0.6 5 1.2 4.4 2 4.4H4.2Z" fill="url(#g0)" stroke="#1a1e24" stroke-width="0.6" stroke-linejoin="round"/>
7 <circle cx="8" cy="9" r="3.6" fill="#1a1e24"/>
8 <circle cx="8" cy="9" r="2.8" fill="url(#g1)"/>
9 <circle cx="7" cy="8" r="0.9" fill="#ffffff" fill-opacity="0.8"/>
10 <path d="M12.2 5.6H14" stroke="#f0c030" stroke-width="1" stroke-linecap="round"/>
11 <path d="M1.4 5.6H3.4" stroke="#ffffff" stroke-opacity="0.5" stroke-width="0.6" stroke-linecap="round"/>
12</svg>
crates/snowbound/assets/tags/coffee.svg created+12
...@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="2" y1="0" x2="12" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c8ccd4"/></linearGradient>
4 </defs>
5 <path d="M5 1.2C4.4 2.2 5.6 2.8 5 3.8M8 1.2C7.4 2.2 8.6 2.8 8 3.8" fill="none" stroke="#9aa0a8" stroke-width="0.8" stroke-linecap="round"/>
6 <path d="M12 7.2C14.8 7 15 11.2 11.6 11.4" fill="none" stroke="#5a6068" stroke-width="1.6"/>
7 <path d="M12 7.2C14.8 7 15 11.2 11.6 11.4" fill="none" stroke="#ffffff" stroke-width="0.7"/>
8 <path d="M1.6 5.4H12.6V10.4C12.6 12.8 10.6 14.4 8.4 14.4H5.8C3.6 14.4 1.6 12.8 1.6 10.4Z" fill="url(#g0)" stroke="#5a6068" stroke-width="0.6" stroke-linejoin="round"/>
9 <path d="M2.2 6H12V6.8H2.2Z" fill="#6a3a18"/>
10 <path d="M0.6 14.4H14.4" stroke="#5a6068" stroke-width="1" stroke-linecap="round"/>
11 <path d="M3.2 8V10.6" stroke="#ffffff" stroke-width="0.8" stroke-linecap="round"/>
12</svg>
crates/snowbound/assets/tags/fire.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" x1="0" y1="0.6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff9a30"/><stop offset="1" stop-color="#d82808"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="6" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff6a0"/><stop offset="1" stop-color="#ffb818"/></linearGradient>
5 </defs>
6 <path d="M8 15.4C4.6 15.4 2.4 13.2 2.4 10.2C2.4 7.4 4.2 5.8 5 3.6C5.8 4.8 6 5.8 5.8 7C7 5.6 7.8 3.2 7.2 0.6C10.2 2 11.2 4.6 11 7C11.6 6.4 11.8 5.6 11.8 4.6C13 6 13.6 8 13.6 10.2C13.6 13.2 11.4 15.4 8 15.4Z" fill="url(#g0)" stroke="#9a1804" stroke-width="0.6" stroke-linejoin="round"/>
7 <path d="M8 15C6.2 15 5 13.8 5 12.2C5 10.4 6.4 9.4 6.8 7.8C7.6 8.6 7.8 9.4 7.6 10.2C8.4 9.6 9 8.4 8.8 6.8C10.4 8 11 9.8 11 11.8C11 13.6 9.8 15 8 15Z" fill="url(#g1)"/>
8</svg>
crates/snowbound/assets/tags/gift.svg created+10
...@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#78c8f8"/><stop offset="1" stop-color="#2070c8"/></linearGradient>
4 </defs>
5 <path d="M8 4.6C6.6 2 3.6 1.4 3.6 3.2C3.6 4.6 6 4.8 8 4.6C10 4.8 12.4 4.6 12.4 3.2C12.4 1.4 9.4 2 8 4.6Z" fill="#e83838" stroke="#901010" stroke-width="0.6" stroke-linejoin="round"/>
6 <path d="M1.4 4.6H14.6V7.4H1.4Z" fill="url(#g0)" stroke="#12408a" stroke-width="0.6"/>
7 <path d="M2.2 7.4H13.8V15.2H2.2Z" fill="url(#g0)" stroke="#12408a" stroke-width="0.6"/>
8 <path d="M6.8 4.6H9.2V15.2H6.8Z" fill="#e83838" stroke="#901010" stroke-width="0.5"/>
9 <path d="M2.4 5.4H6.4" stroke="#ffffff" stroke-opacity="0.6" stroke-width="0.8" stroke-linecap="round"/>
10</svg>
crates/snowbound/assets/tags/laughing.svg created+12
...@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff27a"/><stop offset="1" stop-color="#e8b400"/></linearGradient>
4 </defs>
5 <circle cx="8" cy="8" r="7.4" fill="#b88a00"/>
6 <circle cx="8" cy="8" r="6.8" fill="url(#g0)"/>
7 <ellipse cx="8" cy="4.2" rx="4" ry="2.2" fill="#ffffff" fill-opacity="0.45"/>
8 <path d="M4.2 6.6L6.2 5.6M11.8 6.6L9.8 5.6" stroke="#2a2008" stroke-width="0.9" stroke-linecap="round"/>
9 <path d="M3.8 8.6H12.2C12.2 11.4 10.4 13 8 13C5.6 13 3.8 11.4 3.8 8.6Z" fill="#6a1808" stroke="#2a2008" stroke-width="0.6" stroke-linejoin="round"/>
10 <path d="M4.4 9H11.6C11.5 9.6 11.4 10 11.2 10.2H4.8C4.6 10 4.5 9.6 4.4 9Z" fill="#ffffff"/>
11 <path d="M5.6 12C6.4 12.6 7.2 12.8 8 12.8C8.8 12.8 9.6 12.6 10.4 12C9.8 11.4 9 11.2 8 11.2C7 11.2 6.2 11.4 5.6 12Z" fill="#f06868"/>
12</svg>
crates/snowbound/assets/tags/map-pin.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="0.6" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8070"/><stop offset="1" stop-color="#c01008"/></linearGradient>
4 </defs>
5 <ellipse cx="8" cy="14.6" rx="3" ry="0.9" fill="#000000" fill-opacity="0.25"/>
6 <path d="M8 15.2C8 15.2 2.6 9.4 2.6 6C2.6 3 5 0.6 8 0.6C11 0.6 13.4 3 13.4 6C13.4 9.4 8 15.2 8 15.2Z" fill="url(#g0)" stroke="#7a0804" stroke-width="0.6" stroke-linejoin="round"/>
7 <circle cx="8" cy="6" r="2.2" fill="#ffffff" stroke="#7a0804" stroke-width="0.5"/>
8 <ellipse cx="6" cy="3.2" rx="1.6" ry="0.9" fill="#ffffff" fill-opacity="0.55"/>
9</svg>
crates/snowbound/assets/tags/rocket.svg created+13
...@@ -0,0 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="4" y1="0" x2="12" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#b8c4d4"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="11" x2="0" y2="15.6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff0a0"/><stop offset="1" stop-color="#f06010"/></linearGradient>
5 </defs>
6 <path d="M6.2 12.2C6.4 13.6 7.2 14.6 8 15.6C8.8 14.6 9.6 13.6 9.8 12.2Z" fill="url(#g1)"/>
7 <path d="M5.4 8.6L2.6 11.2V13L5.6 11.8ZM10.6 8.6L13.4 11.2V13L10.4 11.8Z" fill="#d83020" stroke="#8a1810" stroke-width="0.5" stroke-linejoin="round"/>
8 <path d="M8 0.6C10.4 2.4 11.2 5.2 11 8.4C10.9 10 10.4 11.4 9.8 12.4H6.2C5.6 11.4 5.1 10 5 8.4C4.8 5.2 5.6 2.4 8 0.6Z" fill="url(#g0)" stroke="#4a5868" stroke-width="0.6" stroke-linejoin="round"/>
9 <path d="M8 0.6C9.2 1.5 10 2.6 10.5 3.8H5.5C6 2.6 6.8 1.5 8 0.6Z" fill="#d83020"/>
10 <circle cx="8" cy="6.8" r="1.7" fill="#3a8ad8" stroke="#4a5868" stroke-width="0.6"/>
11 <circle cx="7.5" cy="6.3" r="0.6" fill="#ffffff" fill-opacity="0.8"/>
12 <path d="M8 12.4V14" stroke="#8a1810" stroke-width="0.6"/>
13</svg>
crates/snowbound/assets/tags/snowflake.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8f6ff"/><stop offset="1" stop-color="#58a8e8"/></linearGradient>
4 </defs>
5 <g fill="none" stroke-linecap="round" stroke="#2a6cb0" stroke-width="2.4">
6 <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/>
7 <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/>
8 </g>
9 <g fill="none" stroke-linecap="round" stroke="url(#g0)" stroke-width="1.2">
10 <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/>
11 <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/>
12 </g>
13 <circle cx="8" cy="8" r="1.6" fill="#ffffff" stroke="#2a6cb0" stroke-width="0.6"/>
14</svg>
crates/snowbound/assets/tags/sparkles.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" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff8c0"/><stop offset="1" stop-color="#f0b818"/></linearGradient>
4 </defs>
5 <path d="M6.4 2.6C6.9 6 8 7.2 11.4 7.8C8 8.4 6.9 9.6 6.4 13C5.9 9.6 4.8 8.4 1.4 7.8C4.8 7.2 5.9 6 6.4 2.6Z" fill="url(#g0)" stroke="#a87808" stroke-width="0.6" stroke-linejoin="round"/>
6 <path d="M12.4 0.8C12.6 2.4 13.2 3 14.8 3.2C13.2 3.4 12.6 4 12.4 5.6C12.2 4 11.6 3.4 10 3.2C11.6 3 12.2 2.4 12.4 0.8Z" fill="url(#g0)" stroke="#a87808" stroke-width="0.5" stroke-linejoin="round"/>
7 <path d="M12.2 10.2C12.4 11.8 13 12.4 14.6 12.6C13 12.8 12.4 13.4 12.2 15C12 13.4 11.4 12.8 9.8 12.6C11.4 12.4 12 11.8 12.2 10.2Z" fill="url(#g0)" stroke="#a87808" stroke-width="0.5" stroke-linejoin="round"/>
8</svg>
crates/snowbound/assets/tags/surprised.svg created+13
...@@ -0,0 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff27a"/><stop offset="1" stop-color="#e8b400"/></linearGradient>
4 </defs>
5 <circle cx="8" cy="8" r="7.4" fill="#b88a00"/>
6 <circle cx="8" cy="8" r="6.8" fill="url(#g0)"/>
7 <ellipse cx="8" cy="4.2" rx="4" ry="2.2" fill="#ffffff" fill-opacity="0.45"/>
8 <circle cx="5.6" cy="6.6" r="1.3" fill="#ffffff" stroke="#2a2008" stroke-width="0.5"/>
9 <circle cx="10.4" cy="6.6" r="1.3" fill="#ffffff" stroke="#2a2008" stroke-width="0.5"/>
10 <circle cx="5.8" cy="6.8" r="0.6" fill="#2a2008"/>
11 <circle cx="10.2" cy="6.8" r="0.6" fill="#2a2008"/>
12 <ellipse cx="8" cy="11" rx="1.5" ry="1.9" fill="#6a1808" stroke="#2a2008" stroke-width="0.6"/>
13</svg>
crates/snowbound/assets/tags/thumbs-down.svg created+11
...@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <g transform="rotate(180 8 8)">
3 <defs>
4 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8a820"/><stop offset="1" stop-color="#ffe08a"/></linearGradient>
5 <linearGradient id="g1" x1="0" y1="7" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8605a"/><stop offset="1" stop-color="#a82018"/></linearGradient>
6 </defs>
7 <path d="M0.8 7.2H3.8V14.8H0.8Z" fill="url(#g1)" stroke="#6a1008" stroke-width="0.6" stroke-linejoin="round"/>
8 <path d="M4.4 7.4L7.2 3.6C7.6 3 7.8 2.2 7.8 1.4C9 1.2 10 2.2 9.8 3.6L9.4 6.2H13.6C14.6 6.2 15.3 7.1 15 8L13.6 13.6C13.4 14.4 12.8 14.8 12 14.8H4.4Z" fill="url(#g0)" stroke="#9a6a08" stroke-width="0.6" stroke-linejoin="round"/>
9 <path d="M10 8.6H14.2M9.8 10.8H13.8M9.8 12.8H13.2" stroke="#9a6a08" stroke-width="0.5" stroke-linecap="round"/>
10 </g>
11</svg>
crates/snowbound/assets/tags/thumbs-up.svg created+10
...@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe08a"/><stop offset="1" stop-color="#e8a820"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="7" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6aa8f0"/><stop offset="1" stop-color="#2458b0"/></linearGradient>
5 </defs>
6 <path d="M0.8 7.2H3.8V14.8H0.8Z" fill="url(#g1)" stroke="#1a3c7a" stroke-width="0.6" stroke-linejoin="round"/>
7 <path d="M4.4 7.4L7.2 3.6C7.6 3 7.8 2.2 7.8 1.4C9 1.2 10 2.2 9.8 3.6L9.4 6.2H13.6C14.6 6.2 15.3 7.1 15 8L13.6 13.6C13.4 14.4 12.8 14.8 12 14.8H4.4Z" fill="url(#g0)" stroke="#9a6a08" stroke-width="0.6" stroke-linejoin="round"/>
8 <path d="M10 8.6H14.2M9.8 10.8H13.8M9.8 12.8H13.2" stroke="#9a6a08" stroke-width="0.5" stroke-linecap="round"/>
9 <path d="M5.2 8L7.8 4.4" stroke="#ffffff" stroke-opacity="0.7" stroke-width="0.8" stroke-linecap="round"/>
10</svg>
crates/snowbound/assets/tags/trophy.svg created+10
...@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="3.6" y1="0" x2="12.4" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff0a0"/><stop offset="0.6" stop-color="#f0c030"/><stop offset="1" stop-color="#c08810"/></linearGradient>
4 <linearGradient id="g1" x1="0" y1="13" x2="0" y2="15.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a06a38"/><stop offset="1" stop-color="#5a3414"/></linearGradient>
5 </defs>
6 <path d="M4 2.4C1 2.4 1 7 5 8M12 2.4C15 2.4 15 7 11 8" fill="none" stroke="#a87810" stroke-width="1.2"/>
7 <path d="M3.6 1H12.4V4C12.4 7 10.6 9 8.8 9.4V11.4H10.2V13H5.8V11.4H7.2V9.4C5.4 9 3.6 7 3.6 4Z" fill="url(#g0)" stroke="#8a5a08" stroke-width="0.6" stroke-linejoin="round"/>
8 <path d="M4 12.8H12V15.4H4Z" fill="url(#g1)" stroke="#3a2008" stroke-width="0.5"/>
9 <path d="M5.2 2.2V4.6C5.2 5.6 5.6 6.4 6.2 7" fill="none" stroke="#ffffff" stroke-opacity="0.7" stroke-width="0.8" stroke-linecap="round"/>
10</svg>
crates/snowbound/assets/tags/warning.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff080"/><stop offset="1" stop-color="#f0b000"/></linearGradient>
4 </defs>
5 <path d="M8 0.8C8.5 0.8 8.8 1.1 9.1 1.6L15.2 13.4C15.6 14.2 15.1 15 14.2 15H1.8C0.9 15 0.4 14.2 0.8 13.4L6.9 1.6C7.2 1.1 7.5 0.8 8 0.8Z" fill="#9a6a00"/>
6 <path d="M8 2.2L14.2 14H1.8Z" fill="url(#g0)"/>
7 <path d="M7 5.6H9L8.6 10.6H7.4Z" fill="#2a2008"/>
8 <circle cx="8" cy="12.4" r="0.95" fill="#2a2008"/>
9</svg>
crates/snowbound/examples/release_sign.rs created+44
...@@ -0,0 +1,44 @@
1//! Signs releases with the ed25519 key whose public half the app embeds
2//! (`release-key.pub`): `release_sign new KEY` makes a key, readable only by its owner;
3//! `release_sign KEY FILE...` prints each file's signature, in hex, one per line.
4
5use ring::signature::{Ed25519KeyPair, KeyPair};
6use std::io::Write;
7
8const PUBLIC: &str = include_str!("../release-key.pub");
9
10fn hex(bytes: &[u8]) -> String {
11 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
12}
13
14fn main() -> Result<(), Box<dyn std::error::Error>> {
15 let args: Vec<String> = std::env::args().skip(1).collect();
16 match args.as_slice() {
17 [new, key] if new == "new" => {
18 let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
19 .map_err(|_| "Cannot generate a key")?;
20 if let Some(folder) = std::path::Path::new(key).parent() {
21 std::fs::create_dir_all(folder)?;
22 }
23 let mut file = std::fs::OpenOptions::new();
24 file.write(true).create_new(true);
25 #[cfg(unix)]
26 std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600);
27 file.open(key)?.write_all(document.as_ref())?;
28 let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).map_err(|_| "Bad key")?;
29 println!("{}", hex(pair.public_key().as_ref()));
30 }
31 [key, files @ ..] if !files.is_empty() => {
32 let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?)
33 .map_err(|_| format!("{key} is not an ed25519 key"))?;
34 if hex(pair.public_key().as_ref()) != PUBLIC.trim() {
35 return Err(format!("{key} is not the key release-key.pub names").into());
36 }
37 for file in files {
38 println!("{}", hex(pair.sign(&std::fs::read(file)?).as_ref()));
39 }
40 }
41 _ => return Err("Usage: release_sign new KEY | release_sign KEY FILE...".into()),
42 }
43 Ok(())
44}
crates/snowbound/linux/README.md+3-6
...@@ -20,16 +20,13 @@ Edits save to the notebook folder as you type. Settings live in...@@ -20,16 +20,13 @@ Edits save to the notebook folder as you type. Settings live in
20`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR`20`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR`
21puts the latter elsewhere.21puts the latter elsewhere.
2222
23Run `./install.sh` to add Snowbound to your app launcher; it copies the app23**Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and
24into `~/.local`. On Wayland the taskbar, dock and KDE's title bar find24copies it to `~/.local/bin/snowbound`. Options, next to the update settings,
25Snowbound's icon only through this launcher entry, so without it they show a25uninstalls it again; your notebooks stay where they are.
26generic icon.
2726
28The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and27The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and
29other desktops without server-side decorations an Adwaita-style one drawn by28other desktops without server-side decorations an Adwaita-style one drawn by
30Snowbound.29Snowbound.
31`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as
32well.
3330
34## Requirements31## Requirements
3532
crates/snowbound/linux/install.sh deleted-22
...@@ -1,22 +0,0 @@
1#!/bin/sh
2# Installs Snowbound into ~/.local with a launcher entry; with a notebook folder, the
3# launcher opens it.
4set -eu
5here=$(cd "$(dirname "$0")" && pwd)
6data=${XDG_DATA_HOME:-$HOME/.local/share}
7bin=$HOME/.local/bin
8mkdir -p "$bin" "$data/applications"
9cp "$here/bin/snowbound" "$bin/snowbound"
10cp -R "$here/share/icons" "$data/"
11exec_line="$bin/snowbound"
12if [ $# -gt 0 ]; then
13 notebook=$(cd "$1" && pwd)
14 # Desktop entries quote arguments in double quotes, escape \ " ` $ and double %.
15 quoted=$(printf '%s' "$notebook" | sed -e 's/[\\"`$]/\\\\&/g' -e 's/%/%%/g')
16 exec_line="$exec_line --notebook \"$quoted\""
17fi
18sed "s|^Exec=.*|Exec=$exec_line|" "$here/share/applications/snowbound.desktop" \
19 > "$data/applications/snowbound.desktop"
20command -v update-desktop-database >/dev/null && update-desktop-database "$data/applications" || true
21command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$data/icons/hicolor" 2>/dev/null || true
22echo "Installed $bin/snowbound and the Snowbound launcher."
crates/snowbound/linux/package.sh+2-13
...@@ -4,7 +4,6 @@...@@ -4,7 +4,6 @@
4set -eu4set -eu
5root=$(cd "$(dirname "$0")/../../.." && pwd)5root=$(cd "$(dirname "$0")/../../.." && pwd)
6here="$root/crates/snowbound/linux"6here="$root/crates/snowbound/linux"
7icon="$root/crates/snowbound/assets/icon/Snowbound-SnowLeopard.png"
8target="$root/target"7target="$root/target"
9tools="$target/zig"8tools="$target/zig"
10mkdir -p "$tools" "$target/dist"9mkdir -p "$tools" "$target/dist"
...@@ -39,19 +38,9 @@ EOF...@@ -39,19 +38,9 @@ EOF
39 name=snowbound-linux-$arch38 name=snowbound-linux-$arch
40 stage="$target/dist/$name"39 stage="$target/dist/$name"
41 rm -rf "$stage"40 rm -rf "$stage"
42 mkdir -p "$stage/bin" "$stage/share/applications"41 mkdir -p "$stage/bin"
43 cp "$target/$triple/release/snowbound" "$stage/bin/"42 cp "$target/$triple/release/snowbound" "$stage/bin/"
44 cp "$here/snowbound.desktop" "$stage/share/applications/"43 cp "$here/README.md" "$stage/"
45 cp "$here/README.md" "$here/install.sh" "$stage/"
46 for side in 16 24 32 48 64 128 256 512; do
47 folder="$stage/share/icons/hicolor/${side}x$side/apps"
48 mkdir -p "$folder"
49 if command -v sips >/dev/null; then
50 sips -s format png -z "$side" "$side" "$icon" --out "$folder/snowbound.png" >/dev/null
51 else
52 magick "$icon" -resize "${side}x$side" -depth 8 "$folder/snowbound.png"
53 fi
54 done
55 COPYFILE_DISABLE=1 tar --no-xattrs -C "$target/dist" -czf "$target/dist/$name.tar.gz" "$name"44 COPYFILE_DISABLE=1 tar --no-xattrs -C "$target/dist" -czf "$target/dist/$name.tar.gz" "$name"
56 echo "$target/dist/$name.tar.gz"45 echo "$target/dist/$name.tar.gz"
57done46done
crates/snowbound/linux/snowbound.desktop+2-2
...@@ -4,7 +4,7 @@ Name=Snowbound...@@ -4,7 +4,7 @@ Name=Snowbound
4GenericName=Note Taking4GenericName=Note Taking
5Comment=Type and draw notes in OneNote notebooks5Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound6Exec=snowbound
7Icon=snowbound7Icon=net.paperclover.snowbound
8Terminal=false8Terminal=false
9Categories=Office;9Categories=Office;
10StartupWMClass=snowbound10StartupWMClass=net.paperclover.snowbound
crates/snowbound/linux/snowbound.png created
Binary files /dev/null and b/crates/snowbound/linux/snowbound.png differ
crates/snowbound/release-key.pub created+1
...@@ -0,0 +1 @@
15af6766e8e2204ee52f329af4f5233be3b06419c1df0f8c7d82112b31d346b09
crates/snowbound/src/aqua.rs+184-6
...@@ -12,16 +12,29 @@ use objc2::{...@@ -12,16 +12,29 @@ use objc2::{
12 sel,12 sel,
13};13};
14use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize};14use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize};
15use std::collections::HashMap;15use std::{
16 collections::HashMap,
17 sync::atomic::{AtomicBool, Ordering},
18};
16use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};19use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};
17use winit::{20use winit::{
18 raw_window_handle::{HasWindowHandle, RawWindowHandle},21 raw_window_handle::{HasWindowHandle, RawWindowHandle},
19 window::{CursorIcon, Window},22 window::{CursorIcon, Window},
20};23};
2124
25static PRETEND: AtomicBool = AtomicBool::new(false);
26
27/// Lays the app out as on 10.6, for `--screenshot` to draw a Snow Leopard window anywhere.
28pub fn pretend() {
29 PRETEND.store(true, Ordering::Relaxed);
30}
31
22/// Whether the system predates 10.7, which moved scrollers over the content, gave32/// Whether the system predates 10.7, which moved scrollers over the content, gave
23/// NSScroller its styles and let windows resize from any edge.33/// NSScroller its styles and let windows resize from any edge.
24pub(crate) fn before_lion() -> bool {34pub(crate) fn before_lion() -> bool {
35 if PRETEND.load(Ordering::Relaxed) {
36 return true;
37 }
25 let scroller = AnyClass::get("NSScroller").expect("AppKit is linked");38 let scroller = AnyClass::get("NSScroller").expect("AppKit is linked");
26 let styled: bool =39 let styled: bool =
27 unsafe { msg_send![scroller, respondsToSelector: sel!(preferredScrollerStyle)] };40 unsafe { msg_send![scroller, respondsToSelector: sel!(preferredScrollerStyle)] };
...@@ -35,6 +48,7 @@ pub fn textured(window: &Window, row: f32) -> bool {...@@ -35,6 +48,7 @@ pub fn textured(window: &Window, row: f32) -> bool {
35 if !before_lion() {48 if !before_lion() {
36 return false;49 return false;
37 }50 }
51 TEXTURED.store(true, Ordering::Relaxed);
38 let RawWindowHandle::AppKit(handle) =52 let RawWindowHandle::AppKit(handle) =
39 window.window_handle().expect("Live AppKit window").as_raw()53 window.window_handle().expect("Live AppKit window").as_raw()
40 else {54 else {
...@@ -57,6 +71,28 @@ pub fn textured(window: &Window, row: f32) -> bool {...@@ -57,6 +71,28 @@ pub fn textured(window: &Window, row: f32) -> bool {
57 true71 true
58}72}
5973
74static TEXTURED: AtomicBool = AtomicBool::new(false);
75
76/// 10.6 draws a dark line along the lower edge of a textured window's top content border.
77/// The app paints the window's own grey over it, key or not, beneath everything else, so
78/// the gradient runs straight into the notebook's frame and pane.
79pub fn cover_border_line(ui: &mut ui::Ui, width: f32) {
80 if !TEXTURED.load(Ordering::Relaxed) {
81 return;
82 }
83 let grey = if ui.window_focused { 167 } else { 216 };
84 ui.leaf(
85 "border line",
86 ui::Spec {
87 flags: ui::Flags::FLOAT,
88 size: [ui::px(width), ui::px(1.0)],
89 position: [0.0, crate::TITLE + crate::TAB_ROW - 1.0],
90 fill: Some(draw::srgb(grey, grey, grey)),
91 ..ui::Spec::default()
92 },
93 );
94}
95
60/// `-mouseDownCanMoveWindow` for the content view: a textured window drags from any press96/// `-mouseDownCanMoveWindow` for the content view: a textured window drags from any press
61/// on a view that allows it, asking once. The app drags from its toolbar's empty space97/// on a view that allows it, asking once. The app drags from its toolbar's empty space
62/// itself, through `-performWindowDragWithEvent:`.98/// itself, through `-performWindowDragWithEvent:`.
...@@ -84,11 +120,22 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool {...@@ -84,11 +120,22 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool {
84 Bool::YES120 Bool::YES
85}121}
86122
87/// AppKit's scrollers, on systems whose scrollers sit beside the content.123/// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, and its
88pub fn scrollers() -> Option<ui::Scrollers> {124/// scrollers.
125pub fn system_interface(ui: &mut ui::Ui) {
89 if !before_lion() {126 if !before_lion() {
90 return None;127 return;
91 }128 }
129 ui.set_system_font("Lucida Grande");
130 ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) {
131 sampled_scrollers()
132 } else {
133 scrollers()
134 });
135}
136
137/// AppKit's scrollers.
138fn scrollers() -> ui::Scrollers {
92 MainThreadMarker::new().expect("Views belong to the main thread");139 MainThreadMarker::new().expect("Views belong to the main thread");
93 let window_class = objc2_app_kit::NSWindow::class();140 let window_class = objc2_app_kit::NSWindow::class();
94 // A scroller colours its knob by whether its window is key; these windows never show.141 // A scroller colours its knob by whether its window is key; these windows never show.
...@@ -113,7 +160,7 @@ pub fn scrollers() -> Option<ui::Scrollers> {...@@ -113,7 +160,7 @@ pub fn scrollers() -> Option<ui::Scrollers> {
113 let thickness: f64 = unsafe { msg_send![AnyClass::get("NSScroller").unwrap(), scrollerWidth] };160 let thickness: f64 = unsafe { msg_send![AnyClass::get("NSScroller").unwrap(), scrollerWidth] };
114 let mut views: HashMap<(bool, bool), Retained<AnyObject>> = HashMap::new();161 let mut views: HashMap<(bool, bool), Retained<AnyObject>> = HashMap::new();
115 let mut painted: HashMap<[u32; 6], PaintedScroller> = HashMap::new();162 let mut painted: HashMap<[u32; 6], PaintedScroller> = HashMap::new();
116 Some(ui::Scrollers {163 ui::Scrollers {
117 thickness: thickness as f32,164 thickness: thickness as f32,
118 paint: Box::new(move |request: &Scroller| {165 paint: Box::new(move |request: &Scroller| {
119 let key = [166 let key = [
...@@ -152,7 +199,138 @@ pub fn scrollers() -> Option<ui::Scrollers> {...@@ -152,7 +199,138 @@ pub fn scrollers() -> Option<ui::Scrollers> {
152 painted.insert(key, result.clone());199 painted.insert(key, result.clone());
153 result200 result
154 }),201 }),
155 })202 }
203}
204
205/// A scroller's pieces as 10.6 draws them, sampled with `remote.sh art`: a track with its
206/// arrows and no knob, and the same with a knob, 400 pixels long.
207struct Pieces {
208 track: Vec<Vec<[u8; 4]>>,
209 knob: Vec<Vec<[u8; 4]>>,
210}
211
212/// Lines of `png`'s pixels across `axis`, from its start: rows for a vertical scroller,
213/// columns for a horizontal one.
214fn lines(png: &[u8], axis: Axis) -> Vec<Vec<[u8; 4]>> {
215 let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
216 decoder.set_transformations(png::Transformations::normalize_to_color8());
217 let mut reader = decoder.read_info().expect("A sampled scroller is a PNG");
218 let mut bytes = vec![0; reader.output_buffer_size().expect("Small")];
219 let info = reader
220 .next_frame(&mut bytes)
221 .expect("A sampled scroller is a PNG");
222 let [width, height] = [info.width, info.height].map(|side| side as usize);
223 let pixel = |x: usize, y: usize| -> [u8; 4] {
224 let at = (y * width + x) * 4;
225 bytes[at..at + 4].try_into().unwrap()
226 };
227 match axis {
228 Axis::Y => (0..height)
229 .map(|y| (0..width).map(|x| pixel(x, y)).collect())
230 .collect(),
231 Axis::X => (0..width)
232 .map(|x| (0..height).map(|y| pixel(x, y)).collect())
233 .collect(),
234 }
235}
236
237fn pieces(axis: Axis, active: bool) -> Pieces {
238 macro_rules! art {
239 ($name:literal) => {
240 include_bytes!(concat!("../assets/snow-leopard/", $name))
241 };
242 }
243 let [track, full]: [&[u8]; 2] = match (axis, active) {
244 (Axis::Y, true) => [
245 art!("scroller-vertical-track-key.png"),
246 art!("scroller-vertical-key.png"),
247 ],
248 (Axis::Y, false) => [
249 art!("scroller-vertical-track-other.png"),
250 art!("scroller-vertical-other.png"),
251 ],
252 (Axis::X, true) => [
253 art!("scroller-horizontal-track-key.png"),
254 art!("scroller-horizontal-key.png"),
255 ],
256 (Axis::X, false) => [
257 art!("scroller-horizontal-track-other.png"),
258 art!("scroller-horizontal-other.png"),
259 ],
260 };
261 let [track, full] = [track, full].map(|png| lines(png, axis));
262 // The knob is where the two differ.
263 let differs: Vec<usize> = (0..track.len())
264 .filter(|&at| track[at] != full[at])
265 .collect();
266 let knob = full[differs[0]..=differs[differs.len() - 1]].to_vec();
267 Pieces { track, knob }
268}
269
270/// Lines `length` long from `source`: its first `head` and last `tail` lines, and its
271/// middle line repeated between them.
272fn stretch(source: &[Vec<[u8; 4]>], length: usize, head: usize, tail: usize) -> Vec<Vec<[u8; 4]>> {
273 let middle = &source[source.len() / 2];
274 let head = head.min(length);
275 let tail = tail.min(length - head);
276 source[..head]
277 .iter()
278 .chain(std::iter::repeat_n(middle, length - head - tail))
279 .chain(&source[source.len() - tail..])
280 .cloned()
281 .collect()
282}
283
284/// Scrollers put together from 10.6's own pieces, for a Snow Leopard window drawn
285/// elsewhere. Parts lie where AppKit puts them: the track from 4 pixels in to 30 from the
286/// end, then the decrement arrow's 14 and the increment arrow's 16.
287fn sampled_scrollers() -> ui::Scrollers {
288 const THICKNESS: usize = 15;
289 let mut cache: HashMap<(bool, bool), Pieces> = HashMap::new();
290 ui::Scrollers {
291 thickness: THICKNESS as f32,
292 paint: Box::new(move |request: &Scroller| {
293 let pieces = cache
294 .entry((request.axis == Axis::Y, request.active))
295 .or_insert_with(|| pieces(request.axis, request.active));
296 let length = request.length.round().max(60.0) as usize;
297 let mut lines = stretch(&pieces.track, length, 12, 44);
298 let slot = [4.0, length as f32 - 30.0];
299 let room = slot[1] - slot[0];
300 let knob_length = (room * request.proportion).clamp(20.0, room);
301 let start = (slot[0] + request.value * (room - knob_length)).round();
302 let knob = stretch(&pieces.knob, knob_length.round() as usize, 10, 10);
303 for (line, knob) in lines[start as usize..].iter_mut().zip(knob) {
304 *line = knob;
305 }
306 let pixels = match request.axis {
307 Axis::Y => lines.concat(),
308 Axis::X => (0..THICKNESS)
309 .flat_map(|across| lines.iter().map(move |line| line[across]))
310 .collect(),
311 };
312 let rgba = pixels
313 .into_iter()
314 .flat_map(|[r, g, b, a]| {
315 [r, g, b]
316 .map(|v| (u16::from(v) * u16::from(a) / 255) as u8)
317 .into_iter()
318 .chain([a])
319 })
320 .collect();
321 let size = match request.axis {
322 Axis::Y => [THICKNESS as u32, length as u32],
323 Axis::X => [length as u32, THICKNESS as u32],
324 };
325 PaintedScroller {
326 image: draw::RasterImage::new(size, rgba).expect("A scroller is a valid image"),
327 knob: [start, start + knob_length.round()],
328 slot,
329 decrement: [length as f32 - 30.0, length as f32 - 16.0],
330 increment: [length as f32 - 16.0, length as f32],
331 }
332 }),
333 }
156}334}
157335
158unsafe fn paint(336unsafe fn paint(
crates/snowbound/src/art.rs+3
...@@ -19,6 +19,8 @@ pub const CLOCK: &[&str] = art!("icons/clock");...@@ -19,6 +19,8 @@ pub const CLOCK: &[&str] = art!("icons/clock");
19pub const CLOSE: &[&str] = art!("icons/close");19pub const CLOSE: &[&str] = art!("icons/close");
20pub const CONFLICT: &[&str] = art!("icons/conflict");20pub const CONFLICT: &[&str] = art!("icons/conflict");
21pub const EQUATION: &[&str] = art!("icons/equation");21pub const EQUATION: &[&str] = art!("icons/equation");
22pub const FOLDER: &[&str] = art!("icons/folder");
23pub const ICLOUD: &[&str] = art!("icons/icloud");
22pub const FONT_COLOR: &[&str] = art!("icons/font-color");24pub const FONT_COLOR: &[&str] = art!("icons/font-color");
23pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");25pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");
24pub const INDENT: &[&str] = art!("icons/indent");26pub const INDENT: &[&str] = art!("icons/indent");
...@@ -35,6 +37,7 @@ pub const REDO: &[&str] = art!("icons/redo");...@@ -35,6 +37,7 @@ pub const REDO: &[&str] = art!("icons/redo");
35pub const SEARCH: &[&str] = art!("icons/search");37pub const SEARCH: &[&str] = art!("icons/search");
36pub const SECTION: &[&str] = art!("icons/section");38pub const SECTION: &[&str] = art!("icons/section");
37pub const SECTION_GROUP: &[&str] = art!("icons/section-group");39pub const SECTION_GROUP: &[&str] = art!("icons/section-group");
40pub const SERVER: &[&str] = art!("icons/server");
38pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");41pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
39pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");42pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
40pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");43pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");
crates/snowbound/src/attachment.rs+1-1
...@@ -94,7 +94,7 @@ impl State {...@@ -94,7 +94,7 @@ impl State {
94 unstored();94 unstored();
95 return Ok(());95 return Ok(());
96 };96 };
97 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save") {97 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) {
98 std::fs::write(path, bytes)?;98 std::fs::write(path, bytes)?;
99 }99 }
100 Ok(())100 Ok(())
crates/snowbound/src/background.rs+13
...@@ -146,6 +146,19 @@ pub fn menu(...@@ -146,6 +146,19 @@ pub fn menu(
146 );146 );
147 };147 };
148 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {148 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {
149 let name = match cells[index] {
150 Cell::Color(Some(color)) => PAGE_COLORS
151 .iter()
152 .find(|(_, listed)| *listed == color)
153 .map(|(name, _)| *name),
154 Cell::Custom => Some("Custom Color"),
155 Cell::Art(Some(name)) => Some(name),
156 Cell::Rules(Some(index)) => Some(RULE_LINES[index].0),
157 _ => None,
158 };
159 if let Some(name) = name {
160 crate::name(ui, ui.current(), name);
161 }
149 match cells[index] {162 match cells[index] {
150 Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"),163 Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"),
151 Cell::ShowAll => label(ui, "Show All…"),164 Cell::ShowAll => label(ui, "Show All…"),
crates/snowbound/src/commands.rs+116-40
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2//! chords on each platform, when it applies and what it does.2//! chords on each platform, when it applies and what it does.
33
4use crate::recording::{Media, Transport};4use crate::recording::{Media, Transport};
5use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};5use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, platform, search};
6use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle};6use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle};
7use canvas::interaction::{Request, ink::Tool};7use canvas::interaction::{Request, ink::Tool};
8use draw::edit::{Key, Modifiers, NamedKey, Platform};8use draw::edit::{Key, Modifiers, NamedKey, Platform};
...@@ -14,6 +14,7 @@ pub enum Id {...@@ -14,6 +14,7 @@ pub enum Id {
14 Settings,14 Settings,
15 NewNotebook,15 NewNotebook,
16 OpenNotebook,16 OpenNotebook,
17 OpenFromServer,
17 CloseNotebook,18 CloseNotebook,
18 NewSection,19 NewSection,
19 NewSectionGroup,20 NewSectionGroup,
...@@ -32,6 +33,9 @@ pub enum Id {...@@ -32,6 +33,9 @@ pub enum Id {
32 Find,33 Find,
33 Search,34 Search,
34 SearchResults,35 SearchResults,
36 /// The palette listing notebooks, sections and pages to go to.
37 GoTo,
38 /// The palette listing commands, as typing `>` in it does.
35 CommandPalette,39 CommandPalette,
36 Back,40 Back,
37 Forward,41 Forward,
...@@ -42,6 +46,10 @@ pub enum Id {...@@ -42,6 +46,10 @@ pub enum Id {
42 PageList,46 PageList,
43 DarkPages,47 DarkPages,
44 FullPageView,48 FullPageView,
49 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.
50 HideSpelling,
51 /// The Spelling pane, on the next marked word.
52 Spelling,
45 PageColor,53 PageColor,
46 Table,54 Table,
47 Picture,55 Picture,
...@@ -78,6 +86,13 @@ pub enum Id {...@@ -78,6 +86,13 @@ pub enum Id {
78 RemoveTags,86 RemoveTags,
79 FindTags,87 FindTags,
80 Help,88 Help,
89 CheckForUpdates,
90}
91
92/// Whether this platform can run command `id` at all; the toolbar and palette leave out
93/// one it can't.
94pub fn offered(id: Id) -> bool {
95 id != Id::ScreenClipping || cfg!(target_os = "macos")
81}96}
8297
83/// What a menu or toolbar offers: a command, or one of a list's entries.98/// What a menu or toolbar offers: a command, or one of a list's entries.
...@@ -106,7 +121,8 @@ pub enum Choice {...@@ -106,7 +121,8 @@ pub enum Choice {
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
107pub struct Status {122pub struct Status {
108 pub enabled: bool,123 pub enabled: bool,
109 pub checked: bool,124 /// Whether a toggle command is on; `None` for a command that is not a toggle.
125 pub checked: Option<bool>,
110}126}
111127
112/// A key with modifiers; `command` is the shortcut modifier, Control off macOS.128/// A key with modifiers; `command` is the shortcut modifier, Control off macOS.
...@@ -196,6 +212,7 @@ pub const COMMANDS: &[Command] = &[...@@ -196,6 +212,7 @@ pub const COMMANDS: &[Command] = &[
196 row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]),212 row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]),
197 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),213 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),
198 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),214 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),
215 row!(Id::OpenFromServer, "Open Notebook from Server…", NONE, NONE),
199 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),216 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),
200 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),217 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),
201 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),218 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),
...@@ -243,6 +260,7 @@ pub const COMMANDS: &[Command] = &[...@@ -243,6 +260,7 @@ pub const COMMANDS: &[Command] = &[
243 &[cmd('f').option()],260 &[cmd('f').option()],
244 &[key('o').option()],261 &[key('o').option()],
245 ),262 ),
263 row!(Id::GoTo, "Go to Page or Section…", &[cmd('p')], &[cmd('p')]),
246 row!(264 row!(
247 Id::CommandPalette,265 Id::CommandPalette,
248 "Command Palette…",266 "Command Palette…",
...@@ -279,6 +297,13 @@ pub const COMMANDS: &[Command] = &[...@@ -279,6 +297,13 @@ pub const COMMANDS: &[Command] = &[
279 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),297 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),
280 row!(Id::PageList, "Page List", NONE, NONE),298 row!(Id::PageList, "Page List", NONE, NONE),
281 row!(Id::DarkPages, "Dark Pages", NONE, NONE),299 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
300 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
301 row!(
302 Id::Spelling,
303 "Spelling…",
304 &[named(NamedKey::F7)],
305 &[named(NamedKey::F7)]
306 ),
282 // AppKit's Enter Full Screen takes Control-Command-F.307 // AppKit's Enter Full Screen takes Control-Command-F.
283 row!(308 row!(
284 Id::FullPageView,309 Id::FullPageView,
...@@ -443,6 +468,7 @@ pub const COMMANDS: &[Command] = &[...@@ -443,6 +468,7 @@ pub const COMMANDS: &[Command] = &[
443 ),468 ),
444 row!(Id::FindTags, "Find Tags", NONE, NONE),469 row!(Id::FindTags, "Find Tags", NONE, NONE),
445 row!(Id::Help, "Snowbound Help", NONE, NONE),470 row!(Id::Help, "Snowbound Help", NONE, NONE),
471 row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE),
446];472];
447473
448/// The most tags a list holds: MS-ONE's action types 0 to 99 number them.474/// The most tags a list holds: MS-ONE's action types 0 to 99 number them.
...@@ -604,6 +630,8 @@ impl Chord {...@@ -604,6 +630,8 @@ impl Chord {
604 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),630 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),
605 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),631 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),
606 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),632 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),
633 // AppKit's NSF7FunctionKey.
634 Press::Named(NamedKey::F7) => ('\u{f70a}'.to_string(), self.shift),
607 Press::Named(named) => unreachable!("No command takes {named:?}"),635 Press::Named(named) => unreachable!("No command takes {named:?}"),
608 }636 }
609 }637 }
...@@ -616,6 +644,7 @@ impl Chord {...@@ -616,6 +644,7 @@ impl Chord {
616 Press::Named(NamedKey::Space) => "Space".to_owned(),644 Press::Named(NamedKey::Space) => "Space".to_owned(),
617 Press::Named(NamedKey::ArrowLeft) => "←".to_owned(),645 Press::Named(NamedKey::ArrowLeft) => "←".to_owned(),
618 Press::Named(NamedKey::ArrowRight) => "→".to_owned(),646 Press::Named(NamedKey::ArrowRight) => "→".to_owned(),
647 Press::Named(NamedKey::F7) => "F7".to_owned(),
619 _ => key.to_uppercase(),648 _ => key.to_uppercase(),
620 };649 };
621 [650 [
...@@ -690,24 +719,26 @@ impl State {...@@ -690,24 +719,26 @@ impl State {
690 pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status {719 pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status {
691 let session = self.session.as_ref();720 let session = self.session.as_ref();
692 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();721 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
693 let modal = self.options.is_some() || self.link.is_some() || self.tag_list.is_some();722 let modal = self.options.is_some()
723 || self.link.is_some()
724 || self.tag_list.is_some()
725 || self.server.is_some();
694 let page = (session.is_some() || self.temporary) && !modal;726 let page = (session.is_some() || self.temporary) && !modal;
695 let writable = page && !session.is_some_and(|session| session.read_only());727 let writable = page && !session.is_some_and(|session| session.read_only());
696 let text = writable && self.view.accepts_text();728 // Picked drawings leave the text selection behind them unseen, so it takes no edits.
729 let typing = self.view.accepts_text() && self.view.ink_selection().is_empty();
730 let text = writable && typing;
697 // Edit commands act on a focused field instead of the page.731 // Edit commands act on a focused field instead of the page.
698 let field = self732 let field = self.ui.focused_field().is_some();
699 .ui
700 .focused()
701 .is_some_and(|focus| focus != crate::page());
702 let [anchor, focus] = self.view.editor.selection().positions;733 let [anchor, focus] = self.view.editor.selection().positions;
703 let selected = page && !field && self.view.accepts_text() && anchor != focus;734 let selected = page && !field && typing && anchor != focus;
704 let enabled = |enabled| Status {735 let enabled = |enabled| Status {
705 enabled,736 enabled,
706 checked: false,737 checked: None,
707 };738 };
708 let checked = |checked| Status {739 let checked = |checked| Status {
709 enabled: text,740 enabled: text,
710 checked,741 checked: Some(checked),
711 };742 };
712 let id = match choice {743 let id = match choice {
713 Choice::Command(id) => *id,744 Choice::Command(id) => *id,
...@@ -723,10 +754,19 @@ impl State {...@@ -723,10 +754,19 @@ impl State {
723 Id::Settings754 Id::Settings
724 | Id::NewNotebook755 | Id::NewNotebook
725 | Id::OpenNotebook756 | Id::OpenNotebook
757 | Id::OpenFromServer
758 | Id::GoTo
726 | Id::CommandPalette759 | Id::CommandPalette
727 | Id::CustomizeTags760 | Id::CustomizeTags
728 | Id::Help => enabled(!modal),761 | Id::Help
729 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),762 | Id::CheckForUpdates => enabled(!modal),
763 Id::CloseNotebook => enabled(!modal && self.notebook().is_some()),
764 Id::ShowNotebook => enabled(
765 !modal
766 && self
767 .notebook()
768 .is_some_and(|library| library.folder().is_some()),
769 ),
730 Id::NewSection | Id::NewSectionGroup => enabled(770 Id::NewSection | Id::NewSectionGroup => enabled(
731 !modal771 !modal
732 && self772 && self
...@@ -736,17 +776,19 @@ impl State {...@@ -736,17 +776,19 @@ impl State {
736 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {776 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {
737 enabled(!modal && session.is_some())777 enabled(!modal && session.is_some())
738 }778 }
739 Id::PageVersions => {779 Id::PageVersions => session.filter(|_| !modal).map_or(
740 session780 Status {
741 .filter(|_| !modal)781 enabled: false,
742 .map_or_else(Status::default, |session| Status {782 checked: Some(false),
743 enabled: !session.page_versions(session.space).is_empty(),783 },
744 checked: session.shown_history == Some(session.space),784 |session| Status {
745 })785 enabled: !session.page_versions(session.space).is_empty(),
746 }786 checked: Some(session.shown_history == Some(session.space)),
787 },
788 ),
747 Id::FullPageView => Status {789 Id::FullPageView => Status {
748 enabled: !welcome && !modal,790 enabled: !welcome && !modal,
749 checked: self.full_page,791 checked: Some(self.full_page),
750 },792 },
751 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),793 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
752 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),794 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
...@@ -759,58 +801,70 @@ impl State {...@@ -759,58 +801,70 @@ impl State {
759 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page),801 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page),
760 Id::Sidebar => Status {802 Id::Sidebar => Status {
761 enabled: !welcome && !modal,803 enabled: !welcome && !modal,
762 checked: self.sidebar,804 checked: Some(self.sidebar),
763 },805 },
764 Id::PageList => Status {806 Id::PageList => Status {
765 enabled: !modal && session.is_some(),807 enabled: !modal && session.is_some(),
766 checked: self.pages_open,808 checked: Some(self.pages_open),
767 },809 },
768 Id::SearchResults => Status {810 Id::SearchResults => Status {
769 enabled: !modal && session.is_some(),811 enabled: !modal && session.is_some(),
770 checked: matches!(self.search.pane, Some(crate::pane::Pane::Search { .. })),812 checked: Some(matches!(
813 self.search.pane,
814 Some(crate::pane::Pane::Search { .. })
815 )),
771 },816 },
772 Id::FindTags => Status {817 Id::FindTags => Status {
773 enabled: !modal && session.is_some(),818 enabled: !modal && session.is_some(),
774 checked: matches!(self.search.pane, Some(crate::pane::Pane::Tags { .. })),819 checked: Some(matches!(
820 self.search.pane,
821 Some(crate::pane::Pane::Tags { .. })
822 )),
775 },823 },
776 Id::DarkPages => Status {824 Id::DarkPages => Status {
777 enabled: !modal,825 enabled: !modal,
778 checked: !self.light_pages,826 checked: Some(!self.light_pages),
827 },
828 Id::HideSpelling => Status {
829 enabled: !modal && self.spelling.is_some(),
830 checked: Some(self.hide_spelling),
779 },831 },
832 Id::Spelling => enabled(text && self.view.spelling.is_some()),
780 Id::FormatPainter => Status {833 Id::FormatPainter => Status {
781 enabled: text,834 enabled: text,
782 checked: self.painter.is_some(),835 checked: Some(self.painter.is_some()),
783 },836 },
784 Id::PageColor => enabled(writable && session.is_some()),837 Id::PageColor => enabled(writable && session.is_some()),
785 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),838 Id::ScreenClipping => enabled(text && offered(id)),
786 Id::RecordAudio | Id::RecordVideo => {839 Id::RecordAudio | Id::RecordVideo => {
787 let recording = match self.media {840 let recording = match self.media {
788 Media::Recording { video, .. } => Some(video),841 Media::Recording { video, .. } => Some(video),
789 _ => None,842 _ => None,
790 };843 };
791 Status {844 Status {
792 enabled: recording.is_some() || text && !modal,845 enabled: recording.is_some()
793 checked: recording == Some(id == Id::RecordVideo),846 || text && !modal && !matches!(self.media, Media::Saving { .. }),
847 checked: Some(recording == Some(id == Id::RecordVideo)),
794 }848 }
795 }849 }
796 Id::Transport(transport) => self.transport_status(transport),850 Id::Transport(transport) => self.transport_status(transport),
797 Id::InsertSpace => Status {851 Id::InsertSpace => Status {
798 enabled: writable,852 enabled: writable,
799 checked: self.view.inserting_space(),853 checked: Some(self.view.inserting_space()),
800 },854 },
801 Id::SelectType => Status {855 Id::SelectType => Status {
802 enabled: page,856 enabled: page,
803 checked: tool == Tool::Select,857 checked: Some(tool == Tool::Select),
804 },858 },
805 Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => Status {859 Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => Status {
806 enabled: writable,860 enabled: writable,
807 checked: match (id, tool) {861 checked: Some(match (id, tool) {
808 (Id::Pen, Tool::Pen(_))862 (Id::Pen, Tool::Pen(_))
809 | (Id::Eraser, Tool::Eraser)863 | (Id::Eraser, Tool::Eraser)
810 | (Id::Lasso, Tool::Lasso) => true,864 | (Id::Lasso, Tool::Lasso) => true,
811 (Id::Shape(kind), Tool::Shape(shape, _)) => kind == shape,865 (Id::Shape(kind), Tool::Shape(shape, _)) => kind == shape,
812 _ => false,866 _ => false,
813 },867 }),
814 },868 },
815 Id::Table869 Id::Table
816 | Id::Picture870 | Id::Picture
...@@ -832,7 +886,7 @@ impl State {...@@ -832,7 +886,7 @@ impl State {
832 Id::Numbering => checked(format.numbering),886 Id::Numbering => checked(format.numbering),
833 Id::Align(alignment) => checked(format.alignment == Some(alignment)),887 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
834 Id::Tag(place) => match self.tags.get(place) {888 Id::Tag(place) => match self.tags.get(place) {
835 Some(tag) => checked(format.tags.contains(&(tag.clone(), place as u16))),889 Some(tag) => checked(format.tags.contains(&(tag.stored(), place as u16))),
836 None => Status::default(),890 None => Status::default(),
837 },891 },
838 }892 }
...@@ -930,7 +984,7 @@ impl State {...@@ -930,7 +984,7 @@ impl State {
930 Id::Pen984 Id::Pen
931 }985 }
932 };986 };
933 let field = self.ui.focused().filter(|focus| *focus != page());987 let field = self.ui.focused_field();
934 let response = match id {988 let response = match id {
935 Id::Settings => {989 Id::Settings => {
936 self.open_options();990 self.open_options();
...@@ -938,6 +992,7 @@ impl State {...@@ -938,6 +992,7 @@ impl State {
938 }992 }
939 Id::NewNotebook => Work::NewNotebook,993 Id::NewNotebook => Work::NewNotebook,
940 Id::OpenNotebook => Work::OpenNotebook,994 Id::OpenNotebook => Work::OpenNotebook,
995 Id::OpenFromServer => Work::OpenFromServer(None),
941 Id::CloseNotebook => {996 Id::CloseNotebook => {
942 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))997 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))
943 }998 }
...@@ -1011,12 +1066,21 @@ impl State {...@@ -1011,12 +1066,21 @@ impl State {
1011 self.start_search();1066 self.start_search();
1012 return Ok(());1067 return Ok(());
1013 }1068 }
1069 Id::Spelling => {
1070 self.open_spelling_pane();
1071 return Ok(());
1072 }
1014 Id::SearchResults | Id::FindTags => {1073 Id::SearchResults | Id::FindTags => {
1015 self.toggle_pane(id == Id::FindTags);1074 self.toggle_pane(id == Id::FindTags);
1016 return Ok(());1075 return Ok(());
1017 }1076 }
1018 Id::CommandPalette => {1077 Id::GoTo | Id::CommandPalette => {
1019 self.ui.open_popup(crate::palette::id());1078 let query = if id == Id::GoTo {
1079 ""
1080 } else {
1081 crate::palette::COMMANDS
1082 };
1083 ui::popup::open_with(&mut self.ui, crate::palette::id(), query);
1020 return Ok(());1084 return Ok(());
1021 }1085 }
1022 Id::Back | Id::Forward => {1086 Id::Back | Id::Forward => {
...@@ -1077,6 +1141,12 @@ impl State {...@@ -1077,6 +1141,12 @@ impl State {
1077 platform::character_palette();1141 platform::character_palette();
1078 return Ok(());1142 return Ok(());
1079 }1143 }
1144 Id::HideSpelling => {
1145 self.hide_spelling = !self.hide_spelling;
1146 self.show_spelling();
1147 self.save_settings();
1148 return Ok(());
1149 }
1080 Id::DarkPages => {1150 Id::DarkPages => {
1081 self.light_pages = !self.light_pages;1151 self.light_pages = !self.light_pages;
1082 self.follow_color_scheme();1152 self.follow_color_scheme();
...@@ -1130,6 +1200,10 @@ impl State {...@@ -1130,6 +1200,10 @@ impl State {
1130 platform::reveal(HELP);1200 platform::reveal(HELP);
1131 return Ok(());1201 return Ok(());
1132 }1202 }
1203 Id::CheckForUpdates => {
1204 self.updates.check_now();
1205 return Ok(());
1206 }
1133 Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)),1207 Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)),
1134 // The highlighter and font colour apply their last pick.1208 // The highlighter and font colour apply their last pick.
1135 Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)),1209 Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)),
...@@ -1142,7 +1216,9 @@ impl State {...@@ -1142,7 +1216,9 @@ impl State {
1142 Id::ClearFormatting => return format(self, Formatting::Clear),1216 Id::ClearFormatting => return format(self, Formatting::Clear),
1143 Id::Tag(place) => {1217 Id::Tag(place) => {
1144 let tag = self.tags[place].clone();1218 let tag = self.tags[place].clone();
1145 return format(self, Formatting::Tag(tag, place as u16));1219 format(self, Formatting::Tag(tag, place as u16))?;
1220 self.keep_tag_art(place);
1221 return Ok(());
1146 }1222 }
1147 Id::CustomizeTags => {1223 Id::CustomizeTags => {
1148 self.open_customize_tags();1224 self.open_customize_tags();
crates/snowbound/src/desktop_linux.rs created+452
...@@ -0,0 +1,452 @@
1//! The single Linux executable's place on the desktop. Wayland shells without
2//! xdg-toplevel-icon (GNOME) find a window's icon only through a desktop entry named after its
3//! app ID, so a run that isn't installed writes a hidden one and removes it on exit; Install
4//! adds Snowbound to the app menu under the same name, and Uninstall takes back what it wrote.
5
6use std::{
7 ffi::c_void,
8 fs,
9 io::{self, Write},
10 os::fd::{AsFd, FromRawFd},
11 path::{Path, PathBuf},
12 process::{Command, Stdio},
13 ptr::NonNull,
14 sync::Mutex,
15};
16use wayland_client::{
17 Connection, Dispatch, EventQueue, Proxy,
18 backend::{Backend, ObjectId},
19 globals::{GlobalList, GlobalListContents, registry_queue_init},
20 protocol::{wl_buffer, wl_registry, wl_shm, wl_shm_pool},
21};
22use wayland_protocols::xdg::{
23 shell::client::xdg_toplevel::XdgToplevel,
24 toplevel_icon::v1::client::{
25 xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
26 xdg_toplevel_icon_v1::XdgToplevelIconV1,
27 },
28};
29use winit::{
30 event_loop::EventLoop,
31 platform::wayland::{EventLoopExtWayland, WindowExtWayland},
32 raw_window_handle::{HasDisplayHandle, RawDisplayHandle},
33 window::{Icon, Window},
34};
35
36/// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon.
37pub const APP_ID: &str = "net.paperclover.snowbound";
38const TEMPLATE: &str = include_str!("../linux/snowbound.desktop");
39/// 512 pixels square; the smaller sizes are scaled from it.
40const ICON: &[u8] = include_bytes!("../linux/snowbound.png");
41const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512];
42/// Marks the hidden entry of a run that isn't installed, holding its process ID.
43const PORTABLE: &str = "X-Snowbound-Portable";
44
45#[derive(Clone, Copy, PartialEq)]
46enum Installed {
47 No,
48 /// By Install, into the user's data folder.
49 Here,
50 /// By a package, into a system data folder.
51 System,
52}
53
54static INSTALLED: Mutex<Installed> = Mutex::new(Installed::No);
55
56fn installed() -> Installed {
57 *INSTALLED.lock().unwrap()
58}
59
60/// Whether the welcome offers Install.
61pub fn installable() -> bool {
62 installed() == Installed::No
63}
64
65/// Whether Options offers Uninstall.
66pub fn uninstallable() -> bool {
67 installed() == Installed::Here
68}
69
70fn data_home() -> Option<PathBuf> {
71 crate::platform::xdg_base("XDG_DATA_HOME", ".local/share")
72}
73
74fn entry(data: &Path) -> PathBuf {
75 data.join(format!("applications/{APP_ID}.desktop"))
76}
77
78fn theme_icon(data: &Path, side: u32) -> PathBuf {
79 data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))
80}
81
82/// Where Install copies the executable.
83pub fn binary() -> Option<PathBuf> {
84 Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/bin/snowbound"))
85}
86
87/// The hidden entry's icon, in the session's runtime folder, which logging out empties.
88fn runtime_icon() -> Option<PathBuf> {
89 let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
90 Some(runtime.join(format!("{APP_ID}.png")))
91}
92
93/// The process that wrote the hidden entry `text`; None for any other entry.
94fn portable_owner(text: &str) -> Option<i32> {
95 text.lines()
96 .find_map(|line| line.strip_prefix(PORTABLE)?.strip_prefix('='))
97 .and_then(|pid| pid.trim().parse().ok())
98}
99
100/// Before the window opens: notes whether Snowbound is installed, removes the hidden entry a
101/// run that ended without cleaning up left, and writes this run's where the shell needs it.
102pub fn prepare(event_loop: &EventLoop<crate::UserEvent>) {
103 let Some(data) = data_home() else {
104 return;
105 };
106 let path = entry(&data);
107 let owner = fs::read_to_string(&path)
108 .ok()
109 .map(|text| portable_owner(&text));
110 let system = std::env::var("XDG_DATA_DIRS")
111 .ok()
112 .filter(|dirs| !dirs.is_empty())
113 .unwrap_or_else(|| "/usr/local/share:/usr/share".into());
114 let packaged = std::env::split_paths(&system).any(|root| entry(&root).exists());
115 *INSTALLED.lock().unwrap() = match owner {
116 Some(None) => Installed::Here,
117 _ if packaged => Installed::System,
118 _ => Installed::No,
119 };
120 let needed = installed() == Installed::No
121 && event_loop.is_wayland()
122 && !display(event_loop)
123 .and_then(toplevel_icons)
124 .unwrap_or(false);
125 if needed {
126 if let Err(error) = write_portable(&path) {
127 eprintln!("Cannot write {}: {error}", path.display());
128 }
129 } else if let Some(Some(pid)) = owner
130 && unsafe { libc::kill(pid, 0) } != 0
131 {
132 remove_portable(pid);
133 }
134}
135
136fn write_portable(path: &Path) -> io::Result<()> {
137 let icon = runtime_icon().ok_or(io::ErrorKind::NotFound)?;
138 fs::write(&icon, ICON)?;
139 let text = entry_text(&std::env::current_exe()?, &icon.to_string_lossy())
140 + &format!("NoDisplay=true\n{PORTABLE}={}\n", std::process::id());
141 write_entry(path, &text)?;
142 extern "C" fn exiting() {
143 remove_portable(std::process::id() as i32);
144 }
145 unsafe { libc::atexit(exiting) };
146 Ok(())
147}
148
149/// Removes the hidden entry `pid` wrote, leaving any other.
150fn remove_portable(pid: i32) {
151 let Some(path) = data_home().map(|data| entry(&data)) else {
152 return;
153 };
154 let text = fs::read_to_string(&path).unwrap_or_default();
155 if portable_owner(&text) == Some(pid) {
156 let _ = fs::remove_file(path);
157 let _ = runtime_icon().map(fs::remove_file);
158 }
159}
160
161/// The template launching `exec` and showing `icon`, a theme name or an absolute path.
162fn entry_text(exec: &Path, icon: &str) -> String {
163 TEMPLATE
164 .lines()
165 .map(|line| match line.split_once('=') {
166 Some(("Exec", _)) => format!("Exec={}\n", quote(exec)),
167 Some(("Icon", _)) => format!("Icon={icon}\n"),
168 _ => format!("{line}\n"),
169 })
170 .collect()
171}
172
173/// `path` as an Exec argument: quoted, with the quoting's escapes escaped again as the
174/// entry's strings require, and field codes' `%` doubled.
175fn quote(path: &Path) -> String {
176 let mut quoted = String::from("\"");
177 for character in path.to_string_lossy().chars() {
178 match character {
179 '"' | '`' | '$' => quoted.push_str("\\\\"),
180 '\\' => quoted.push_str("\\\\\\"),
181 '%' => quoted.push('%'),
182 _ => {}
183 }
184 quoted.push(character);
185 }
186 quoted + "\""
187}
188
189/// Writes whole, so the shell watching the folder never reads half an entry.
190fn write_entry(path: &Path, text: &str) -> io::Result<()> {
191 fs::create_dir_all(path.parent().ok_or(io::ErrorKind::NotFound)?)?;
192 let partial = path.with_extension("partial");
193 fs::write(&partial, text)?;
194 fs::rename(partial, path)
195}
196
197fn pixels() -> image::RgbaImage {
198 let mut decoder = png::Decoder::new(io::Cursor::new(ICON));
199 decoder.set_transformations(png::Transformations::normalize_to_color8());
200 let mut reader = decoder.read_info().expect("The icon is a PNG");
201 let mut rgba = vec![
202 0;
203 reader
204 .output_buffer_size()
205 .expect("The icon fits in memory")
206 ];
207 let frame = reader.next_frame(&mut rgba).expect("The icon is a PNG");
208 image::RgbaImage::from_raw(frame.width, frame.height, rgba).expect("The icon is RGBA")
209}
210
211fn scaled(icon: &image::RgbaImage, side: u32) -> image::RgbaImage {
212 image::imageops::resize(icon, side, side, image::imageops::FilterType::Lanczos3)
213}
214
215/// For X11, which takes the icon from the window.
216pub fn window_icon() -> Option<Icon> {
217 let icon = scaled(&pixels(), 128);
218 Icon::from_rgba(icon.into_raw(), 128, 128).ok()
219}
220
221struct Wayland;
222
223impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Wayland {
224 fn event(
225 _: &mut Self,
226 _: &wl_registry::WlRegistry,
227 _: wl_registry::Event,
228 _: &GlobalListContents,
229 _: &Connection,
230 _: &wayland_client::QueueHandle<Self>,
231 ) {
232 }
233}
234
235wayland_client::delegate_noop!(Wayland: ignore wl_shm::WlShm);
236wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool);
237wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer);
238wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1);
239wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1);
240
241fn display(handle: &impl HasDisplayHandle) -> Option<NonNull<c_void>> {
242 match handle.display_handle().ok()?.as_raw() {
243 RawDisplayHandle::Wayland(wayland) => Some(wayland.display),
244 _ => None,
245 }
246}
247
248/// A queue of this process's own on winit's Wayland connection.
249fn connect(display: NonNull<c_void>) -> Option<(Connection, GlobalList, EventQueue<Wayland>)> {
250 // Safety: winit's display outlives the window and event loop that lend it.
251 let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) };
252 let connection = Connection::from_backend(backend);
253 let (globals, queue) = registry_queue_init(&connection).ok()?;
254 Some((connection, globals, queue))
255}
256
257fn toplevel_icons(display: NonNull<c_void>) -> Option<bool> {
258 let (_, globals, _) = connect(display)?;
259 let name = XdgToplevelIconManagerV1::interface().name;
260 Some(
261 globals
262 .contents()
263 .with_list(|list| list.iter().any(|global| global.interface == name)),
264 )
265}
266
267/// Gives the window its icon where the compositor takes one through xdg-toplevel-icon, as KWin
268/// does.
269pub fn set_toplevel_icon(window: &Window) -> Option<()> {
270 let toplevel = window.xdg_toplevel()?;
271 let (connection, globals, mut queue) = connect(display(window)?)?;
272 let handle = queue.handle();
273 let manager: XdgToplevelIconManagerV1 = globals.bind(&handle, 1..=1, ()).ok()?;
274 let shm: wl_shm::WlShm = globals.bind(&handle, 1..=1, ()).ok()?;
275 // Safety: winit's xdg_toplevel lives as long as the window.
276 let id = unsafe { ObjectId::from_ptr(XdgToplevel::interface(), toplevel.as_ptr().cast()) };
277 let toplevel = XdgToplevel::from_id(&connection, id.ok()?).ok()?;
278 let icon = pixels();
279 // wl_shm's ARGB8888 is premultiplied, little-endian BGRA.
280 let sides = &SIZES[..SIZES.len() - 1];
281 let mut bytes = Vec::new();
282 for &side in sides {
283 for pixel in scaled(&icon, side).pixels() {
284 let [red, green, blue, alpha] = pixel.0;
285 let premultiply = |channel: u8| (channel as u32 * alpha as u32 / 255) as u8;
286 bytes.extend([
287 premultiply(blue),
288 premultiply(green),
289 premultiply(red),
290 alpha,
291 ]);
292 }
293 }
294 // Safety: memfd_create returns a new descriptor this File then owns.
295 let fd = unsafe { libc::memfd_create(c"snowbound-icon".as_ptr(), libc::MFD_CLOEXEC) };
296 if fd < 0 {
297 return None;
298 }
299 let mut file = unsafe { fs::File::from_raw_fd(fd) };
300 file.write_all(&bytes).ok()?;
301 let pool = shm.create_pool(file.as_fd(), bytes.len() as i32, &handle, ());
302 let icon = manager.create_icon(&handle, ());
303 let mut offset = 0;
304 let buffers: Vec<_> = sides
305 .iter()
306 .map(|&side| {
307 let stride = side as i32 * 4;
308 let buffer = pool.create_buffer(
309 offset,
310 side as i32,
311 side as i32,
312 stride,
313 wl_shm::Format::Argb8888,
314 &handle,
315 (),
316 );
317 offset += stride * side as i32;
318 icon.add_buffer(&buffer, 1);
319 buffer
320 })
321 .collect();
322 manager.set_icon(&toplevel, Some(&icon));
323 // The toplevel keeps its icon; the buffers must outlive the icon object.
324 icon.destroy();
325 for buffer in buffers {
326 buffer.destroy();
327 }
328 pool.destroy();
329 manager.destroy();
330 queue.roundtrip(&mut Wayland).ok()?;
331 Some(())
332}
333
334/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the
335/// app menu, and says where Uninstall is.
336pub fn install() {
337 match try_install() {
338 Ok(()) => {
339 *INSTALLED.lock().unwrap() = Installed::Here;
340 crate::platform::inform(
341 "Snowbound is in your app menu",
342 "To uninstall it, open Options from the notebook menu. Uninstall is under Updates.",
343 );
344 }
345 Err(error) => crate::platform::alert("Couldn't install Snowbound", &error.to_string()),
346 }
347}
348
349fn try_install() -> io::Result<()> {
350 let (data, binary) = data_home().zip(binary()).ok_or(io::ErrorKind::NotFound)?;
351 let running = std::env::current_exe()?;
352 if fs::canonicalize(&running)? != fs::canonicalize(&binary).unwrap_or_default() {
353 fs::create_dir_all(binary.parent().ok_or(io::ErrorKind::NotFound)?)?;
354 let partial = binary.with_extension("partial");
355 fs::copy(&running, &partial)?;
356 fs::rename(partial, &binary)?;
357 }
358 let icon = pixels();
359 for side in SIZES {
360 let path = theme_icon(&data, side);
361 fs::create_dir_all(path.parent().ok_or(io::ErrorKind::NotFound)?)?;
362 if side == icon.width() {
363 fs::write(path, ICON)?;
364 continue;
365 }
366 let mut encoder = png::Encoder::new(fs::File::create(path)?, side, side);
367 encoder.set_color(png::ColorType::Rgba);
368 encoder.set_depth(png::BitDepth::Eight);
369 encoder
370 .write_header()?
371 .write_image_data(&scaled(&icon, side))?;
372 }
373 // Replaces this run's hidden entry, which exiting then leaves alone.
374 write_entry(&entry(&data), &entry_text(&binary, APP_ID))?;
375 let _ = runtime_icon().map(fs::remove_file);
376 refresh_icon_cache(&data);
377 Ok(())
378}
379
380/// After confirming, removes what Install wrote; notebooks stay where they are.
381pub fn uninstall() {
382 let (Some(data), Some(binary)) = (data_home(), binary()) else {
383 return;
384 };
385 let detail = format!(
386 "Snowbound leaves the app menu and {} is deleted. Your notebooks stay where they are.",
387 binary.display()
388 );
389 if !crate::platform::confirm("Uninstall Snowbound?", &detail, "Cancel", "Uninstall") {
390 return;
391 }
392 let mut failed = None;
393 let icons = SIZES.map(|side| theme_icon(&data, side));
394 for path in [entry(&data), binary].iter().chain(&icons) {
395 if let Err(error) = fs::remove_file(path)
396 && error.kind() != io::ErrorKind::NotFound
397 {
398 failed.get_or_insert(error);
399 }
400 }
401 // Install made the size folders; each goes once empty, up to hicolor.
402 for icon in &icons {
403 for folder in icon.ancestors().skip(1).take(3) {
404 let _ = fs::remove_dir(folder);
405 }
406 }
407 refresh_icon_cache(&data);
408 match failed {
409 Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()),
410 None => *INSTALLED.lock().unwrap() = Installed::No,
411 }
412}
413
414/// Brings the icon theme's cache up to date where the user keeps one.
415fn refresh_icon_cache(data: &Path) {
416 let _ = Command::new("gtk-update-icon-cache")
417 .arg("-q")
418 .arg(data.join("icons/hicolor"))
419 .stdout(Stdio::null())
420 .stderr(Stdio::null())
421 .status();
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn exec_paths_are_quoted_then_escaped_for_the_entry() {
430 assert_eq!(
431 quote(Path::new("/home/a b/snowbound")),
432 r#""/home/a b/snowbound""#
433 );
434 assert_eq!(quote(Path::new(r#"/x/$"`\%y"#)), r#""/x/\\$\\"\\`\\\\%%y""#);
435 }
436
437 #[test]
438 fn only_the_hidden_entry_names_an_owner() {
439 let installed = entry_text(Path::new("/bin/snowbound"), APP_ID);
440 assert!(installed.contains("\nExec=\"/bin/snowbound\"\n"));
441 assert!(installed.contains(&format!("\nIcon={APP_ID}\n")));
442 assert_eq!(portable_owner(&installed), None);
443 let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n");
444 assert_eq!(portable_owner(&hidden), Some(42));
445 }
446
447 #[test]
448 fn the_icon_is_square_and_as_large_as_the_largest_size() {
449 let icon = pixels();
450 assert_eq!([icon.width(), icon.height()], [SIZES[7]; 2]);
451 }
452}
crates/snowbound/src/icloud_linux.rs created+49
...@@ -0,0 +1,49 @@
1//! iCloud Drive is Apple's; on Linux no folder is in it.
2
3use notebook::session::{Background, Notebook, Section};
4use std::path::{Path, PathBuf};
5
6pub fn ubiquitous(_: &Path) -> bool {
7 false
8}
9
10pub fn folder() -> Option<PathBuf> {
11 None
12}
13
14pub fn look_up(_: impl Fn() + Send + 'static) {}
15
16pub fn drive() -> Option<PathBuf> {
17 None
18}
19
20pub fn section(
21 notebook: &Notebook,
22 path: &str,
23 notify: impl Fn() + Send + 'static,
24) -> Result<Section, notebook::Error> {
25 notebook.section(path, notify)
26}
27
28pub fn lone_section(
29 file: &Path,
30 cache: &Path,
31 notify: impl Fn() + Send + 'static,
32) -> Result<Section, notebook::Error> {
33 Section::open(file, cache, notify)
34}
35
36pub fn background(
37 notebook: &Notebook,
38 notify: impl Fn() + Send + 'static,
39) -> Result<Background, notebook::Error> {
40 notebook.background(false, true, notify)
41}
42
43pub struct Presenter;
44
45pub fn presenter(_: &Path, _: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Presenter> {
46 None
47}
48
49pub fn on_account_change(_: impl Fn() + 'static) {}
crates/snowbound/src/icloud_macos.rs created+550
...@@ -0,0 +1,550 @@
1//! iCloud Drive: notebooks in folders it keeps, their section files read and written under
2//! file coordination (so the iCloud daemon never swaps a file mid-commit), the conflict
3//! versions it keeps beside a file merged into it, and the app's own folder in iCloud Drive.
4//! Mac OS X 10.9 and earlier have no iCloud Drive; nothing here runs there.
5
6use block2::{RcBlock, StackBlock};
7use notebook::{
8 Remote, Version,
9 session::{Background, Notebook, Section},
10};
11use objc2::{
12 ClassType, DeclaredClass, declare_class, msg_send_id, mutability,
13 rc::Retained,
14 runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject},
15};
16use objc2_foundation::{
17 NSFileCoordinator, NSFileCoordinatorReadingOptions, NSFileCoordinatorWritingOptions,
18 NSFileManager, NSFilePresenter, NSFileVersion, NSNotification, NSNotificationCenter,
19 NSOperationQueue, NSString, NSURL, NSURLIsUbiquitousItemKey,
20 NSURLUbiquitousItemDownloadingStatusKey, NSURLUbiquitousItemDownloadingStatusNotDownloaded,
21};
22use onestore::{CommitError, CommitState, Stamp, Transaction};
23use std::{
24 cell::Cell,
25 collections::BTreeMap,
26 io,
27 path::{Path, PathBuf},
28 ptr::NonNull,
29 sync::Mutex,
30 time::Duration,
31};
32
33/// The app's iCloud container, which iCloud Drive shows as a folder named Snowbound.
34const CONTAINER: &str = "iCloud.net.paperclover.snowbound";
35
36/// How long local edits in iCloud Drive wait for a pause in typing before they publish:
37/// each publication is an upload, and one concurrent with another device's is a conflict
38/// version.
39pub const PAUSE: Duration = Duration::from_secs(3);
40
41/// Whether this Mac has iCloud Drive at all: Mac OS X 10.10 (Darwin 14) and later.
42fn available() -> bool {
43 let mut name: libc::utsname = unsafe { std::mem::zeroed() };
44 (unsafe { libc::uname(&mut name) }) == 0
45 && unsafe { std::ffi::CStr::from_ptr(name.release.as_ptr()) }
46 .to_str()
47 .ok()
48 .and_then(|release| release.split('.').next()?.parse::<u32>().ok())
49 .is_some_and(|major| major >= 14)
50}
51
52fn url(path: &Path) -> Option<Retained<NSURL>> {
53 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })
54}
55
56fn resource(url: &NSURL, key: &NSString) -> Option<Retained<AnyObject>> {
57 let mut value = None;
58 unsafe { url.getResourceValue_forKey_error(&mut value, key) }.ok()?;
59 value
60}
61
62/// Whether iCloud Drive keeps the file or folder at `path`, as first asked: the answer
63/// outlasts a signed-out account's folder, which is then gone.
64pub fn ubiquitous(path: &Path) -> bool {
65 static KNOWN: Mutex<BTreeMap<PathBuf, bool>> = Mutex::new(BTreeMap::new());
66 let Ok(mut known) = KNOWN.lock() else {
67 return false;
68 };
69 *known.entry(path.to_owned()).or_insert_with(|| {
70 available()
71 && url(path)
72 .and_then(|url| resource(&url, unsafe { NSURLIsUbiquitousItemKey }))
73 .is_some_and(|value| {
74 let yes: bool = unsafe { objc2::msg_send![&value, boolValue] };
75 yes
76 })
77 })
78}
79
80/// The app's own folder in iCloud Drive as `look_up` last found it.
81static FOLDER: Mutex<Option<PathBuf>> = Mutex::new(None);
82
83/// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's
84/// Snowbound folder), as `look_up` last found it: while the app carries the container's
85/// entitlement and iCloud Drive is on.
86pub fn folder() -> Option<PathBuf> {
87 FOLDER.lock().ok()?.clone()
88}
89
90/// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the
91/// iCloud daemon can take a while, then calls `done`.
92pub fn look_up(done: impl Fn() + Send + 'static) {
93 std::thread::spawn(move || {
94 let found = container();
95 if let Ok(mut folder) = FOLDER.lock() {
96 *folder = found;
97 }
98 done();
99 });
100}
101
102fn container() -> Option<PathBuf> {
103 if !available() {
104 return None;
105 }
106 let manager = unsafe { NSFileManager::defaultManager() };
107 let url =
108 unsafe { manager.URLForUbiquityContainerIdentifier(Some(&NSString::from_str(CONTAINER))) }?;
109 let documents = PathBuf::from(unsafe { url.path() }?.to_string()).join("Documents");
110 std::fs::create_dir_all(&documents).ok()?;
111 Some(documents)
112}
113
114/// The top of iCloud Drive, where it is on.
115pub fn drive() -> Option<PathBuf> {
116 if !available() {
117 return None;
118 }
119 let drive = PathBuf::from(std::env::var_os("HOME")?)
120 .join("Library/Mobile Documents/com~apple~CloudDocs");
121 drive.is_dir().then_some(drive)
122}
123
124/// `work` within a coordinated read or, with `write`, write of `path`, as the iCloud daemon
125/// waits for it and it for the daemon.
126fn coordinated<T>(path: &Path, write: bool, work: impl FnOnce() -> T) -> io::Result<T> {
127 let url = url(path).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
128 let coordinator =
129 unsafe { NSFileCoordinator::initWithFilePresenter(NSFileCoordinator::alloc(), None) };
130 let (work, done) = (Cell::new(Some(work)), Cell::new(None));
131 let accessor = StackBlock::new(|_: NonNull<NSURL>| {
132 if let Some(work) = work.take() {
133 done.set(Some(work()));
134 }
135 });
136 let mut error = None;
137 unsafe {
138 if write {
139 coordinator.coordinateWritingItemAtURL_options_error_byAccessor(
140 &url,
141 NSFileCoordinatorWritingOptions::NSFileCoordinatorWritingForMerging,
142 Some(&mut error),
143 &accessor,
144 );
145 } else {
146 coordinator.coordinateReadingItemAtURL_options_error_byAccessor(
147 &url,
148 NSFileCoordinatorReadingOptions::empty(),
149 Some(&mut error),
150 &accessor,
151 );
152 }
153 }
154 done.take().ok_or_else(|| {
155 io::Error::other(error.map_or_else(
156 || "The file could not be coordinated".to_owned(),
157 |error| error.localizedDescription().to_string(),
158 ))
159 })
160}
161
162/// Asks for a file iCloud Drive keeps elsewhere, which a read would otherwise wait for: the
163/// sync thread tries again later, while the section's replica serves it.
164fn present(path: &Path) -> io::Result<()> {
165 let Some(url) = url(path) else {
166 return Ok(());
167 };
168 let status = resource(&url, unsafe { NSURLUbiquitousItemDownloadingStatusKey });
169 let missing = status.is_some_and(|status| {
170 let equal: bool = unsafe {
171 objc2::msg_send![&status, isEqual: NSURLUbiquitousItemDownloadingStatusNotDownloaded]
172 };
173 equal
174 });
175 if !missing {
176 return Ok(());
177 }
178 let manager = unsafe { NSFileManager::defaultManager() };
179 let _ = unsafe { manager.startDownloadingUbiquitousItemAtURL_error(&url) };
180 Err(io::Error::new(
181 io::ErrorKind::NotConnected,
182 "Downloading from iCloud Drive",
183 ))
184}
185
186/// A section file in iCloud Drive, read and published under file coordination, with the
187/// conflict versions iCloud keeps beside it.
188pub struct Coordinated(pub PathBuf);
189
190fn uncommitted(error: io::Error) -> CommitError {
191 CommitError {
192 state: CommitState::NotCommitted,
193 error,
194 }
195}
196
197impl Coordinated {
198 fn conflicts(&self) -> io::Result<Vec<Retained<NSFileVersion>>> {
199 let url = url(&self.0).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
200 Ok(
201 unsafe { NSFileVersion::unresolvedConflictVersionsOfItemAtURL(&url) }
202 .map(|versions| versions.to_vec_retained())
203 .unwrap_or_default(),
204 )
205 }
206
207 fn conflict(&self, id: &str) -> io::Result<Retained<NSFileVersion>> {
208 self.conflicts()?
209 .into_iter()
210 .find(|version| version_path(version).as_deref() == Some(Path::new(id)))
211 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
212 }
213}
214
215fn version_path(version: &NSFileVersion) -> Option<PathBuf> {
216 Some(PathBuf::from(unsafe { version.URL().path() }?.to_string()))
217}
218
219impl Remote for Coordinated {
220 fn read(&mut self) -> io::Result<Vec<u8>> {
221 present(&self.0)?;
222 coordinated(&self.0, false, || onestore::read_file(&self.0))?
223 }
224
225 fn stamp(&mut self) -> io::Result<Stamp> {
226 present(&self.0)?;
227 coordinated(&self.0, false, || {
228 use std::io::Read;
229 let mut file = std::fs::File::open(&self.0)?;
230 let mut header = [0; 1024];
231 file.read_exact(&mut header)?;
232 Ok(Stamp {
233 header,
234 length: file.metadata()?.len(),
235 })
236 })?
237 }
238
239 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
240 present(&self.0).map_err(uncommitted)?;
241 coordinated(&self.0, true, || transaction.commit_file(&self.0)).map_err(uncommitted)?
242 }
243
244 fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> {
245 coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)?
246 }
247
248 fn versions(&mut self) -> io::Result<Vec<Version>> {
249 Ok(self
250 .conflicts()?
251 .into_iter()
252 .filter_map(|version| {
253 Some(Version {
254 id: version_path(&version)?.to_string_lossy().into_owned(),
255 device: unsafe { version.localizedNameOfSavingComputer() }
256 .map(|name| name.to_string()),
257 })
258 })
259 .collect())
260 }
261
262 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
263 let version = self.conflict(id)?;
264 if !unsafe { version.hasLocalContents() } {
265 return Err(io::Error::new(
266 io::ErrorKind::NotConnected,
267 "Downloading another device’s version",
268 ));
269 }
270 std::fs::read(id)
271 }
272
273 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
274 let version = match self.conflict(id) {
275 Ok(version) => version,
276 // Another device resolved it first.
277 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
278 Err(error) => return Err(error),
279 };
280 coordinated(&self.0, true, || {
281 if keep {
282 // Beside the file, under a name of its own: "Section (Clover's iPhone).one".
283 let device = unsafe { version.localizedNameOfSavingComputer() }
284 .map_or_else(|| "another device".to_owned(), |name| name.to_string());
285 let stem = self.0.file_stem().unwrap_or_default().to_string_lossy();
286 let folder = self.0.parent().unwrap_or(Path::new("."));
287 let kept = (1..)
288 .map(|n| match n {
289 1 => folder.join(format!("{stem} ({device}).one")),
290 n => folder.join(format!("{stem} ({device} {n}).one")),
291 })
292 .find(|kept| !kept.exists())
293 .unwrap_or_default();
294 std::fs::write(kept, std::fs::read(id)?)?;
295 }
296 unsafe {
297 version.setResolved(true);
298 version
299 .removeAndReturnError()
300 .map_err(|error| io::Error::other(error.localizedDescription().to_string()))
301 }
302 })?
303 }
304}
305
306/// Opens the section at catalog `path` of a notebook in iCloud Drive.
307pub fn section(
308 notebook: &Notebook,
309 path: &str,
310 notify: impl Fn() + Send + 'static,
311) -> Result<Section, notebook::Error> {
312 let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?;
313 section.set_pause(PAUSE);
314 Ok(section)
315}
316
317/// Opens a section file in iCloud Drive on its own.
318pub fn lone_section(
319 file: &Path,
320 cache: &Path,
321 notify: impl Fn() + Send + 'static,
322) -> Result<Section, notebook::Error> {
323 let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?;
324 section.set_pause(PAUSE);
325 Ok(section)
326}
327
328/// Keeps a notebook in iCloud Drive in sync while its sections are not open: offline copies
329/// of every section, as a folder kept elsewhere needs, each section checked every
330/// `Background::UNWATCHED` for conflict versions, which change no file, and at once when the
331/// presenter or the folder's watch reports it.
332pub fn background(
333 notebook: &Notebook,
334 notify: impl Fn() + Send + 'static,
335) -> Result<Background, notebook::Error> {
336 notebook.background_with(false, true, |file| Coordinated(file.to_owned()), notify)
337}
338
339struct Ivars {
340 url: Retained<NSURL>,
341 root: PathBuf,
342 queue: Retained<NSOperationQueue>,
343 changed: Box<dyn Fn(Vec<String>) + Send + Sync>,
344}
345
346declare_class!(
347 /// Hears what other processes, the iCloud daemon among them, do to a notebook folder.
348 struct FolderPresenter;
349
350 unsafe impl ClassType for FolderPresenter {
351 type Super = NSObject;
352 type Mutability = mutability::InteriorMutable;
353 const NAME: &'static str = "SnowboundFolderPresenter";
354 }
355
356 impl DeclaredClass for FolderPresenter {
357 type Ivars = Ivars;
358 }
359
360 unsafe impl NSObjectProtocol for FolderPresenter {}
361
362 unsafe impl NSFilePresenter for FolderPresenter {
363 #[method_id(presentedItemURL)]
364 unsafe fn presented_item_url(&self) -> Option<Retained<NSURL>> {
365 Some(self.ivars().url.clone())
366 }
367
368 #[method_id(presentedItemOperationQueue)]
369 unsafe fn presented_item_operation_queue(&self) -> Retained<NSOperationQueue> {
370 self.ivars().queue.clone()
371 }
372
373 #[method(presentedSubitemDidChangeAtURL:)]
374 unsafe fn subitem_did_change(&self, url: &NSURL) {
375 self.report(url);
376 }
377
378 #[method(presentedSubitemDidAppearAtURL:)]
379 unsafe fn subitem_did_appear(&self, url: &NSURL) {
380 self.report(url);
381 }
382
383 #[method(presentedSubitemAtURL:didGainVersion:)]
384 unsafe fn subitem_did_gain_version(&self, url: &NSURL, _: &NSFileVersion) {
385 self.report(url);
386 }
387
388 #[method(presentedSubitemAtURL:didResolveConflictVersion:)]
389 unsafe fn subitem_did_resolve_conflict_version(
390 &self,
391 url: &NSURL,
392 _: &NSFileVersion,
393 ) {
394 self.report(url);
395 }
396 }
397);
398
399impl FolderPresenter {
400 fn report(&self, url: &NSURL) {
401 let Some(path) = (unsafe { url.path() }) else {
402 return;
403 };
404 let path = PathBuf::from(path.to_string());
405 let ivars = self.ivars();
406 let relative = path
407 .strip_prefix(&ivars.root)
408 .map(|path| path.to_string_lossy().replace('\\', "/"))
409 .unwrap_or_default();
410 (ivars.changed)(vec![relative]);
411 }
412}
413
414/// Reports changes to a folder in iCloud Drive until dropped.
415pub struct Presenter(Retained<FolderPresenter>);
416
417// The presenter is only registered and removed, each safe from any thread.
418unsafe impl Send for Presenter {}
419unsafe impl Sync for Presenter {}
420
421/// Hears the changes other processes make below the folder at `root`, and the conflict
422/// versions iCloud Drive gains for its files: `changed` hears their paths relative to it,
423/// `/`-separated.
424pub fn presenter(
425 root: &Path,
426 changed: impl Fn(Vec<String>) + Send + Sync + 'static,
427) -> Option<Presenter> {
428 if !available() {
429 return None;
430 }
431 let root = root.canonicalize().ok()?;
432 let queue = unsafe { NSOperationQueue::new() };
433 unsafe { queue.setMaxConcurrentOperationCount(1) };
434 let presenter = FolderPresenter::alloc().set_ivars(Ivars {
435 url: url(&root)?,
436 root,
437 queue,
438 changed: Box::new(changed),
439 });
440 let presenter: Retained<FolderPresenter> = unsafe { msg_send_id![super(presenter), init] };
441 unsafe { NSFileCoordinator::addFilePresenter(ProtocolObject::from_ref(&*presenter)) };
442 Some(Presenter(presenter))
443}
444
445impl Drop for Presenter {
446 fn drop(&mut self) {
447 unsafe {
448 NSFileCoordinator::removeFilePresenter(ProtocolObject::from_ref(&*self.0));
449 self.0.ivars().queue.waitUntilAllOperationsAreFinished();
450 }
451 }
452}
453
454/// Calls `changed` on the main thread whenever the iCloud account signs out, signs in or
455/// switches, or iCloud Drive is turned off, for as long as the app runs.
456pub fn on_account_change(changed: impl Fn() + 'static) {
457 if !available() {
458 return;
459 }
460 let block = RcBlock::new(move |_: NonNull<NSNotification>| changed());
461 unsafe {
462 let center = NSNotificationCenter::defaultCenter();
463 let observer = center.addObserverForName_object_queue_usingBlock(
464 Some(&NSString::from_str(
465 "NSUbiquityIdentityDidChangeNotification",
466 )),
467 None,
468 Some(&NSOperationQueue::mainQueue()),
469 &block,
470 );
471 // Observes for the app's life.
472 std::mem::forget(observer);
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 /// A notebook in a scratch folder of this Mac's iCloud Drive opens, publishes an edit under
481 /// file coordination and reads back; the folder is deleted after.
482 #[test]
483 #[ignore = "writes a scratch folder in this Mac's iCloud Drive"]
484 fn a_notebook_in_icloud_drive_publishes_under_coordination() {
485 let drive = drive().expect("iCloud Drive is on");
486 let root = drive.join(format!("snowbound-icloud-test-{}", std::process::id()));
487 let cache =
488 std::env::temp_dir().join(format!("snowbound-icloud-cache-{}", std::process::id()));
489 let page = onestore::PageCreation::new(None, Some(""), "Test").unwrap();
490 let result = std::panic::catch_unwind(|| {
491 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
492 assert!(ubiquitous(&root));
493 let path = notebook.catalog().sections[0].path.clone();
494 let section = section(&notebook, &path, || {}).unwrap();
495 let (space, ..) = section.pages().unwrap()[0].clone();
496 let title = section
497 .page(space)
498 .unwrap()
499 .objects
500 .iter()
501 .find_map(|object| match object {
502 onestore::page::PageObject::Title(title) => {
503 title.outlines[0].paragraphs[0].text().map(|text| text.id)
504 }
505 _ => None,
506 })
507 .unwrap();
508 section
509 .apply(
510 "Test",
511 onestore::op::Edit {
512 at: 134_030_000_000_000_000,
513 ops: vec![onestore::op::Op::Page {
514 space,
515 op: onestore::op::PageOp::Text {
516 text: title,
517 range: 0..0,
518 with: "In iCloud".into(),
519 },
520 }],
521 },
522 )
523 .unwrap();
524 // Wakes skip the pause in typing, as leaving the window does.
525 let file = root.join(&path);
526 let mut remote = Coordinated(file.clone());
527 let deadline = std::time::Instant::now() + Duration::from_secs(30);
528 loop {
529 section.wake();
530 let arena = onestore::Arena::default();
531 let titles = onestore::Section::open(&arena, remote.read().unwrap())
532 .unwrap()
533 .pages()
534 .unwrap();
535 if titles[0].1 == "In iCloud" {
536 break;
537 }
538 assert!(std::time::Instant::now() < deadline, "{titles:?}");
539 std::thread::sleep(Duration::from_millis(100));
540 }
541 assert!(remote.versions().unwrap().is_empty());
542 section.close().unwrap();
543 });
544 let root = root.as_path();
545 assert!(root.starts_with(&drive) && root != drive);
546 let _ = std::fs::remove_dir_all(root);
547 let _ = std::fs::remove_dir_all(&cache);
548 result.unwrap();
549 }
550}
crates/snowbound/src/library.rs+298-16
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1//! Open notebooks: where each lives and the sections its tabs offer.1//! Open notebooks: where each lives and the sections its tabs offer.
22
3use canvas::{editor::NoteTag, gpu::TagArt};
3use notebook::discover::{Folder, SectionState};4use notebook::discover::{Folder, SectionState};
4use notebook::session::{Background, Notebook, Section};5use notebook::session::{Background, Notebook, Section};
5use notebook::smb::{Client, Credentials};6use notebook::smb::{Client, Credentials};
...@@ -99,6 +100,116 @@ impl Mount {...@@ -99,6 +100,116 @@ impl Mount {
99 root: root.join("/"),100 root: root.join("/"),
100 })101 })
101 }102 }
103
104 /// The folder an address typed or kept names: `smb://[domain;][user@]server[:port]/share/folder`,
105 /// `\\server\share\folder`, or either without its scheme; the share may be left to choose.
106 pub fn from_address(text: &str) -> Option<Self> {
107 let text = text.trim();
108 let rest = match text.get(..6) {
109 Some(scheme) if scheme.eq_ignore_ascii_case("smb://") => &text[6..],
110 _ => text.trim_start_matches(['/', '\\']),
111 }
112 .replace('\\', "/");
113 let (authority, path) = rest.split_once('/').unwrap_or((&rest, ""));
114 let (account, server) = match authority.rsplit_once('@') {
115 Some((account, server)) => (account, server),
116 None => ("", authority),
117 };
118 if server.is_empty() || server.contains(char::is_whitespace) {
119 return None;
120 }
121 let account = account.split(':').next().unwrap_or_default();
122 let (domain, user) = account.split_once(';').unwrap_or(("", account));
123 let mut parts = path.split('/').filter(|part| !part.is_empty()).map(decode);
124 let user = decode(user);
125 Some(Self {
126 server: server.to_owned(),
127 share: parts.next().unwrap_or_default(),
128 user: (!user.is_empty() && !user.eq_ignore_ascii_case("guest")).then_some(user),
129 domain: decode(domain),
130 root: parts.collect::<Vec<_>>().join("/"),
131 })
132 }
133
134 /// The address `from_address` reads back, naming the account but never a password.
135 pub fn url(&self) -> String {
136 let mut url = String::from("smb://");
137 if let Some(user) = &self.user {
138 if !self.domain.is_empty() {
139 url += &format!("{};", encode(&self.domain));
140 }
141 url += &format!("{}@", encode(user));
142 }
143 url += &self.server;
144 for part in [&self.share, &self.root] {
145 if !part.is_empty() {
146 url += &format!("/{}", encode(part));
147 }
148 }
149 url
150 }
151
152 /// The server's name or address without a port.
153 pub fn host(&self) -> &str {
154 match self.server.rsplit_once(':') {
155 Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => host,
156 _ => &self.server,
157 }
158 }
159
160 /// Where the embedded client dials: the server, on SMB's port unless it names another.
161 pub fn endpoint(&self) -> String {
162 if self.host() == self.server {
163 format!("{}:445", self.server)
164 } else {
165 self.server.clone()
166 }
167 }
168
169 /// The notebook's name: its folder's, or the share's where it fills the share.
170 fn name(&self) -> String {
171 self.root
172 .rsplit('/')
173 .find(|part| !part.is_empty())
174 .unwrap_or(&self.share)
175 .to_owned()
176 }
177}
178
179/// `text` with `%XX` escapes decoded.
180fn decode(text: &str) -> String {
181 let bytes = text.as_bytes();
182 let mut decoded = Vec::with_capacity(bytes.len());
183 let mut at = 0;
184 while at < bytes.len() {
185 match text
186 .get(at + 1..at + 3)
187 .filter(|_| bytes[at] == b'%')
188 .and_then(|hex| u8::from_str_radix(hex, 16).ok())
189 {
190 Some(byte) => {
191 decoded.push(byte);
192 at += 3;
193 }
194 None => {
195 decoded.push(bytes[at]);
196 at += 1;
197 }
198 }
199 }
200 String::from_utf8_lossy(&decoded).into_owned()
201}
202
203/// `text` escaped for an address, `/` kept.
204fn encode(text: &str) -> String {
205 text.bytes()
206 .map(|byte| match byte {
207 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
208 char::from(byte).to_string()
209 }
210 byte => format!("%{byte:02X}"),
211 })
212 .collect()
102}213}
103214
104/// An account on an SMB server.215/// An account on an SMB server.
...@@ -111,7 +222,6 @@ pub struct Login {...@@ -111,7 +222,6 @@ pub struct Login {
111222
112impl Login {223impl Login {
113 /// The guest account a mount without one signed in as.224 /// The guest account a mount without one signed in as.
114 #[cfg(any(target_os = "macos", test))]
115 pub fn guest(mount: &Mount) -> Self {225 pub fn guest(mount: &Mount) -> Self {
116 Self {226 Self {
117 user: String::new(),227 user: String::new(),
...@@ -130,7 +240,7 @@ struct Server {...@@ -130,7 +240,7 @@ struct Server {
130impl Server {240impl Server {
131 fn connect(&self) -> io::Result<Client> {241 fn connect(&self) -> io::Result<Client> {
132 Client::connect(242 Client::connect(
133 &self.mount.server,243 &self.mount.endpoint(),
134 &self.mount.share,244 &self.mount.share,
135 Credentials {245 Credentials {
136 username: &self.login.user,246 username: &self.login.user,
...@@ -169,6 +279,8 @@ pub struct Library {...@@ -169,6 +279,8 @@ pub struct Library {
169 pub background: Option<Arc<Background>>,279 pub background: Option<Arc<Background>>,
170 /// Reports the changes to a mounted notebook's folder to `background`.280 /// Reports the changes to a mounted notebook's folder to `background`.
171 watch: Option<Arc<crate::watch::Watch>>,281 watch: Option<Arc<crate::watch::Watch>>,
282 /// The art the notebook's tags draw with, as its `.snowbound` folder maps them.
283 tag_art: Mutex<Arc<TagArt>>,
172}284}
173285
174impl Library {286impl Library {
...@@ -177,6 +289,25 @@ impl Library {...@@ -177,6 +289,25 @@ impl Library {
177 /// account the system keeps for the mount, as OneNote's own client coordinates with289 /// account the system keeps for the mount, as OneNote's own client coordinates with
178 /// OneNote; without that account it opens through the mount.290 /// OneNote; without that account it opens through the mount.
179 pub fn notebook(location: &str, cache: &Path) -> Self {291 pub fn notebook(location: &str, cache: &Path) -> Self {
292 if let Some(mount) = server_address(location) {
293 let login = match mount.user {
294 Some(_) => crate::platform::smb_login(&mount),
295 None => Ok(Login::guest(&mount)),
296 };
297 return login
298 .and_then(|login| Self::on_share(location, mount.clone(), login, cache))
299 .unwrap_or_else(|reason| Self {
300 location: location.to_owned(),
301 name: mount.name(),
302 notebook: Err(reason),
303 server: None,
304 notice: None,
305 cache: cache.to_owned(),
306 background: None,
307 watch: None,
308 tag_art: Default::default(),
309 });
310 }
180 let mut notice = None;311 let mut notice = None;
181 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {312 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {
182 match crate::platform::smb_login(&mount)313 match crate::platform::smb_login(&mount)
...@@ -199,6 +330,9 @@ impl Library {...@@ -199,6 +330,9 @@ impl Library {
199 name: file_name(Path::new(location)),330 name: file_name(Path::new(location)),
200 background,331 background,
201 watch,332 watch,
333 tag_art: Mutex::new(Arc::new(
334 notebook.as_ref().map(read_tag_art).unwrap_or_default(),
335 )),
202 notebook: notebook.map(Some).map_err(|error| error.to_string()),336 notebook: notebook.map(Some).map_err(|error| error.to_string()),
203 server: None,337 server: None,
204 notice,338 notice,
...@@ -215,7 +349,9 @@ impl Library {...@@ -215,7 +349,9 @@ impl Library {
215 cache: &Path,349 cache: &Path,
216 ) -> Result<Self, String> {350 ) -> Result<Self, String> {
217 let server = Arc::new(Server { mount, login });351 let server = Arc::new(Server { mount, login });
218 let client = server.connect().map_err(|error| error.to_string())?;352 let client = server.connect().map_err(|error| {
353 crate::server::refusal(&error, &server.mount, server.login.user.is_empty())
354 })?;
219 let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache)355 let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache)
220 .map_err(|error| error.to_string())?;356 .map_err(|error| error.to_string())?;
221 let connect = Arc::clone(&server);357 let connect = Arc::clone(&server);
...@@ -230,7 +366,11 @@ impl Library {...@@ -230,7 +366,11 @@ impl Library {
230 background.watch(notebook.replicas());366 background.watch(notebook.replicas());
231 Ok(Self {367 Ok(Self {
232 location: location.to_owned(),368 location: location.to_owned(),
233 name: file_name(Path::new(location)),369 name: match server_address(location) {
370 Some(_) => server.mount.name(),
371 None => file_name(Path::new(location)),
372 },
373 tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))),
234 notebook: Ok(Some(notebook)),374 notebook: Ok(Some(notebook)),
235 server: Some(server),375 server: Some(server),
236 notice: None,376 notice: None,
...@@ -240,6 +380,50 @@ impl Library {...@@ -240,6 +380,50 @@ impl Library {
240 })380 })
241 }381 }
242382
383 /// The art the notebook's tags draw with.
384 pub fn tag_art(&self) -> Arc<TagArt> {
385 Arc::clone(
386 &self
387 .tag_art
388 .lock()
389 .unwrap_or_else(|poisoned| poisoned.into_inner()),
390 )
391 }
392
393 /// Draws `tag` with its art, `bytes`, in this notebook from now on, and keeps the art in
394 /// the notebook's folder on a thread of its own; a section opened on its own keeps none.
395 pub fn map_tag_art(self: &Arc<Self>, tag: &NoteTag, bytes: Vec<u8>) {
396 let (Some(art), Ok(Some(_))) = (&tag.art, &self.notebook) else {
397 return;
398 };
399 let Some(sources) = canvas::gpu::art_sources(art, || Some(bytes.clone())) else {
400 return;
401 };
402 {
403 let mut mapped = self
404 .tag_art
405 .lock()
406 .unwrap_or_else(|poisoned| poisoned.into_inner());
407 if mapped.art(&tag.label, tag.shape) == Some(art.as_str()) {
408 return;
409 }
410 Arc::make_mut(&mut mapped).map(&tag.label, tag.shape, art, sources);
411 }
412 let extension = art.rsplit('.').next().unwrap_or_default().to_owned();
413 let (library, tag) = (Arc::clone(self), tag.clone());
414 std::thread::spawn(move || {
415 let kept = library.reopen().and_then(|notebook| {
416 Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?)
417 });
418 if let Err(error) = kept {
419 eprintln!(
420 "{}: keeping the art of tag {:?} failed: {error}",
421 library.location, tag.label
422 );
423 }
424 });
425 }
426
243 /// This notebook read again, the way it was opened, for changing its structure.427 /// This notebook read again, the way it was opened, for changing its structure.
244 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {428 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
245 Ok(match &self.server {429 Ok(match &self.server {
...@@ -274,6 +458,7 @@ impl Library {...@@ -274,6 +458,7 @@ impl Library {
274 cache: self.cache.clone(),458 cache: self.cache.clone(),
275 background: self.background.clone(),459 background: self.background.clone(),
276 watch,460 watch,
461 tag_art: Mutex::new(self.tag_art()),
277 }462 }
278 }463 }
279464
...@@ -285,6 +470,7 @@ impl Library {...@@ -285,6 +470,7 @@ impl Library {
285 name: file_name(Path::new(location)),470 name: file_name(Path::new(location)),
286 background,471 background,
287 watch,472 watch,
473 tag_art: Default::default(),
288 notebook: Ok(Some(notebook)),474 notebook: Ok(Some(notebook)),
289 server: None,475 server: None,
290 notice: None,476 notice: None,
...@@ -303,6 +489,7 @@ impl Library {...@@ -303,6 +489,7 @@ impl Library {
303 cache: cache.to_owned(),489 cache: cache.to_owned(),
304 background: None,490 background: None,
305 watch: None,491 watch: None,
492 tag_art: Default::default(),
306 }493 }
307 }494 }
308495
...@@ -333,7 +520,7 @@ impl Library {...@@ -333,7 +520,7 @@ impl Library {
333 root => format!("{root}/{path}"),520 root => format!("{root}/{path}"),
334 };521 };
335 let cache = notebook.replica_path(path)?;522 let cache = notebook.replica_path(path)?;
336 std::fs::create_dir_all(self.cache.join("smb"))?;523 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
337 let replica = if cache.exists() {524 let replica = if cache.exists() {
338 notebook::Replica::open(&cache)525 notebook::Replica::open(&cache)
339 } else {526 } else {
...@@ -348,7 +535,13 @@ impl Library {...@@ -348,7 +535,13 @@ impl Library {
348 Section::resume_smb(file, replica, LIMIT, connect, notifier())535 Section::resume_smb(file, replica, LIMIT, connect, notifier())
349 })536 })
350 }537 }
538 (Ok(Some(notebook)), None) if self.in_icloud() => {
539 crate::icloud::section(notebook, path, notifier())
540 }
351 (Ok(Some(notebook)), None) => notebook.section(path, notifier()),541 (Ok(Some(notebook)), None) => notebook.section(path, notifier()),
542 (Ok(None), _) if self.in_icloud() => {
543 crate::icloud::lone_section(Path::new(path), &self.cache, notifier())
544 }
352 (Ok(None), _) => Section::open(path, &self.cache, notifier()),545 (Ok(None), _) => Section::open(path, &self.cache, notifier()),
353 (Err(error), _) => return Err(error.clone().into()),546 (Err(error), _) => return Err(error.clone().into()),
354 };547 };
...@@ -379,7 +572,20 @@ impl Library {...@@ -379,7 +572,20 @@ impl Library {
379 }572 }
380 let server = self.server.as_ref()?;573 let server = self.server.as_ref()?;
381 let file = file.strip_prefix(&server.mount.root).ok()?;574 let file = file.strip_prefix(&server.mount.root).ok()?;
382 Some(Path::new(&self.location).join(file))575 Some(self.folder()?.join(file))
576 }
577
578 /// The notebook's folder or section file on this computer; none for a notebook opened
579 /// straight from its server.
580 pub fn folder(&self) -> Option<&Path> {
581 server_address(&self.location)
582 .is_none()
583 .then(|| Path::new(&self.location))
584 }
585
586 /// Whether iCloud Drive keeps the notebook, or the section opened on its own.
587 pub fn in_icloud(&self) -> bool {
588 self.folder().is_some_and(crate::icloud::ubiquitous)
383 }589 }
384590
385 /// How the notebook's files are reached.591 /// How the notebook's files are reached.
...@@ -390,6 +596,7 @@ impl Library {...@@ -390,6 +596,7 @@ impl Library {
390 server.mount.server, server.mount.share596 server.mount.server, server.mount.share
391 ),597 ),
392 (None, Some(_)) => "The system’s mount of the share".to_owned(),598 (None, Some(_)) => "The system’s mount of the share".to_owned(),
599 (None, None) if self.in_icloud() => "iCloud Drive".to_owned(),
393 (None, None) => "Folder on this computer".to_owned(),600 (None, None) => "Folder on this computer".to_owned(),
394 }601 }
395 }602 }
...@@ -467,6 +674,15 @@ impl Library {...@@ -467,6 +674,15 @@ impl Library {
467 }674 }
468}675}
469676
677/// The share folder a notebook opened from its server's address lives in, as its location
678/// keeps it.
679pub fn server_address(location: &str) -> Option<Mount> {
680 location
681 .starts_with("smb://")
682 .then(|| Mount::from_address(location))
683 .flatten()
684}
685
470/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.686/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.
471pub fn recycle_bin(path: &str) -> bool {687pub fn recycle_bin(path: &str) -> bool {
472 path.rsplit('/').next() == Some("OneNote_RecycleBin")688 path.rsplit('/').next() == Some("OneNote_RecycleBin")
...@@ -475,6 +691,27 @@ pub fn recycle_bin(path: &str) -> bool {...@@ -475,6 +691,27 @@ pub fn recycle_bin(path: &str) -> bool {
475/// A mounted notebook's background sync, following Work Offline, and the watch that reports691/// A mounted notebook's background sync, following Work Offline, and the watch that reports
476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a692/// 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.693/// share, and where the system does not report its server's changes, checked more often.
694/// The art `notebook` maps its tags to, each picture read once a run.
695fn read_tag_art(notebook: &Notebook) -> TagArt {
696 let mut read = TagArt::default();
697 let mappings = notebook.tag_art().unwrap_or_else(|error| {
698 eprintln!("Reading the notebook's tag art failed: {error}");
699 Vec::new()
700 });
701 for mapping in mappings {
702 let bytes = || {
703 notebook
704 .tag_art_file(&mapping.art)
705 .inspect_err(|error| eprintln!("Reading tag art {} failed: {error}", mapping.art))
706 .ok()
707 };
708 if let Some(sources) = canvas::gpu::art_sources(&mapping.art, bytes) {
709 read.map(&mapping.name, mapping.shape, &mapping.art, sources);
710 }
711 }
712 read
713}
714
478fn local_background(715fn local_background(
479 notebook: &mut Notebook,716 notebook: &mut Notebook,
480 location: &str,717 location: &str,
...@@ -482,8 +719,11 @@ fn local_background(...@@ -482,8 +719,11 @@ fn local_background(
482 let reports = Arc::new(OnceLock::new());719 let reports = Arc::new(OnceLock::new());
483 let watch = watcher(location, Arc::clone(&reports));720 let watch = watcher(location, Arc::clone(&reports));
484 let copies = !crate::watch::on_this_computer(Path::new(location));721 let copies = !crate::watch::on_this_computer(Path::new(location));
485 let Some(background) = notebook722 let background = match crate::icloud::ubiquitous(Path::new(location)) {
486 .background(watch.is_some(), copies, notify_background)723 true => crate::icloud::background(notebook, notify_background),
724 false => notebook.background(watch.is_some(), copies, notify_background),
725 };
726 let Some(background) = background
487 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))727 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
488 .ok()728 .ok()
489 else {729 else {
...@@ -619,27 +859,54 @@ pub fn locate(path: &Path) -> Located {...@@ -619,27 +859,54 @@ pub fn locate(path: &Path) -> Located {
619mod tests {859mod tests {
620 use super::*;860 use super::*;
621861
622 /// A notebook on a share opens, edits and publishes through the embedded client, as a862 /// Removes the folder at `path` on the share and everything in it.
623 /// mounted share's notebook does: `ONESTORE_SMB_LAB=127.0.0.1:PORT` (a disposable Samba863 fn remove_tree(client: &Client, path: &str) {
624 /// share `agent`, guest access; `tools/w7/linux_vm.py up NAME`).864 for entry in client.read_dir(path, 10_000).unwrap_or_default() {
865 let inner = format!("{path}/{}", entry.name);
866 if entry.attributes & 0x10 != 0 {
867 remove_tree(client, &inner);
868 } else {
869 client.delete(&inner).unwrap();
870 }
871 }
872 let _ = client.delete(path);
873 }
874
875 /// A notebook opened from its server's address opens, edits and publishes through the
876 /// embedded client: `ONESTORE_SMB_LAB=HOST:PORT` with a share `agent` (guest, or
877 /// `ONESTORE_SMB_LAB_USER` and `ONESTORE_SMB_LAB_PASSWORD`; `tools/w7/linux_vm.py up
878 /// NAME`). Its notebook lives in the folder `ONESTORE_SMB_LAB_ROOT` (a fresh one by
879 /// default), removed before and after unless `ONESTORE_SMB_LAB_KEEP` keeps it.
625 #[test]880 #[test]
626 #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]881 #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
627 fn a_notebook_on_a_share_opens_through_the_embedded_client() {882 fn a_notebook_on_a_share_opens_through_the_embedded_client() {
628 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();883 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
629 let root = format!("snowbound-{}", std::process::id());884 let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| {
885 let nanos = std::time::SystemTime::now()
886 .duration_since(std::time::UNIX_EPOCH)
887 .unwrap()
888 .subsec_nanos();
889 format!("snowbound-{}-{nanos}", std::process::id())
890 });
891 let user = std::env::var("ONESTORE_SMB_LAB_USER").ok();
630 let mount = Mount {892 let mount = Mount {
631 server: address,893 server: address,
632 share: "agent".into(),894 share: "agent".into(),
633 user: None,895 user: user.clone(),
634 domain: String::new(),896 domain: String::new(),
635 root: root.clone(),897 root: root.clone(),
636 };898 };
637 let login = Login::guest(&mount);899 let login = Login {
900 user: user.unwrap_or_default(),
901 password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(),
902 domain: String::new(),
903 };
638 let server = Server {904 let server = Server {
639 mount: mount.clone(),905 mount: mount.clone(),
640 login: login.clone(),906 login: login.clone(),
641 };907 };
642 let client = Arc::new(server.connect().unwrap());908 let client = Arc::new(server.connect().unwrap());
909 remove_tree(&client, &root);
643 client.create_directory(&root).unwrap();910 client.create_directory(&root).unwrap();
644 let cache = std::env::temp_dir().join(format!("snowbound-share-{}", std::process::id()));911 let cache = std::env::temp_dir().join(format!("snowbound-share-{}", std::process::id()));
645 let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap();912 let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap();
...@@ -647,7 +914,9 @@ mod tests {...@@ -647,7 +914,9 @@ mod tests {
647 .unwrap()914 .unwrap()
648 .create_section("", "New Section 1", &page)915 .create_section("", "New Section 1", &page)
649 .unwrap();916 .unwrap();
650 let library = Library::on_share("/Volumes/agent/lab", mount, login, &cache).unwrap();917 let location = mount.url();
918 let library = Library::on_share(&location, mount, login, &cache).unwrap();
919 assert!(library.folder().is_none());
651 let path = library.first_section().unwrap();920 let path = library.first_section().unwrap();
652 let section = library.open(&path, || {}).unwrap();921 let section = library.open(&path, || {}).unwrap();
653 let (space, ..) = section.pages().unwrap()[0].clone();922 let (space, ..) = section.pages().unwrap()[0].clone();
...@@ -681,7 +950,14 @@ mod tests {...@@ -681,7 +950,14 @@ mod tests {
681 let deadline = std::time::Instant::now() + Duration::from_secs(60);950 let deadline = std::time::Instant::now() + Duration::from_secs(60);
682 let file = format!("{root}/{path}");951 let file = format!("{root}/{path}");
683 loop {952 loop {
684 let bytes = client.read_storage(&file, LIMIT).unwrap();953 // A publication in progress holds the file from readers for a moment.
954 let bytes = match client.read_storage(&file, LIMIT) {
955 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
956 std::thread::sleep(Duration::from_millis(200));
957 continue;
958 }
959 read => read.unwrap(),
960 };
685 let arena = onestore::Arena::default();961 let arena = onestore::Arena::default();
686 let titles = onestore::Section::open(&arena, bytes)962 let titles = onestore::Section::open(&arena, bytes)
687 .unwrap()963 .unwrap()
...@@ -695,7 +971,12 @@ mod tests {...@@ -695,7 +971,12 @@ mod tests {
695 std::thread::sleep(Duration::from_millis(200));971 std::thread::sleep(Duration::from_millis(200));
696 }972 }
697 section.close().unwrap();973 section.close().unwrap();
974 drop(library);
698 let _ = std::fs::remove_dir_all(&cache);975 let _ = std::fs::remove_dir_all(&cache);
976 if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() {
977 remove_tree(&client, &root);
978 assert!(client.read_dir(&root, 1).is_err(), "the folder is gone");
979 }
699 }980 }
700981
701 #[test]982 #[test]
...@@ -722,6 +1003,7 @@ mod tests {...@@ -722,6 +1003,7 @@ mod tests {
722 cache: PathBuf::new(),1003 cache: PathBuf::new(),
723 background: None,1004 background: None,
724 watch: None,1005 watch: None,
1006 tag_art: Default::default(),
725 };1007 };
726 let shown = |root, file| library(root).local(Path::new(file));1008 let shown = |root, file| library(root).local(Path::new(file));
727 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));1009 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));
crates/snowbound/src/link.rs+83-11
...@@ -90,7 +90,10 @@ fn clipboard_link(...@@ -90,7 +90,10 @@ fn clipboard_link(
90impl State {90impl State {
91 /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links.91 /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links.
92 pub(crate) fn open_link_dialog(&mut self) {92 pub(crate) fn open_link_dialog(&mut self) {
93 let (text, address) = self.view.link_prefill();93 // As OneNote 2010's, Link does nothing over a selection across paragraphs.
94 let Some((text, address)) = self.view.link_prefill() else {
95 return;
96 };
94 self.link = Some(LinkDialog {97 self.link = Some(LinkDialog {
95 text,98 text,
96 address,99 address,
...@@ -113,12 +116,6 @@ impl State {...@@ -113,12 +116,6 @@ impl State {
113 }116 }
114 let theme = ui.theme.clone();117 let theme = ui.theme.clone();
115 let row = theme.font_size * 2.0;118 let row = theme.font_size * 2.0;
116 let window = ui.rect(Id::ROOT).unwrap_or_default();
117 let height = ui.rect(id()).map_or(360.0, |rect| rect[3] - rect[1]);
118 let [left, top] = [
119 ((window[2] - WIDTH) / 2.0).max(0.0),
120 ((window[3] - height) / 2.0).max(0.0),
121 ];
122 let fields = [text_field(), address_field(), search_field()];119 let fields = [text_field(), address_field(), search_field()];
123 let entered =120 let entered =
124 ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter);121 ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter);
...@@ -133,10 +130,14 @@ impl State {...@@ -133,10 +130,14 @@ impl State {
133 radius: 8.0,130 radius: 8.0,
134 pad: [16.0, 12.0],131 pad: [16.0, 12.0],
135 gap: 4.0,132 gap: 4.0,
136 anchor: Some(Anchor::Below([left, top, left, top])),133 anchor: Some(Anchor::Dialog),
134 role: Some(accesskit::Role::Dialog),
137 ..Spec::default()135 ..Spec::default()
138 },136 },
139 );137 );
138 if let Some(node) = ui.access(id()) {
139 node.set_label("Link");
140 }
140 let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| {141 let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| {
141 if !label.is_empty() {142 if !label.is_empty() {
142 ui.leaf(143 ui.leaf(
...@@ -162,6 +163,9 @@ impl State {...@@ -162,6 +163,9 @@ impl State {
162 ..Spec::default()163 ..Spec::default()
163 },164 },
164 );165 );
166 if let Some(node) = ui.access(id) {
167 node.set_label(label.trim_end_matches(':'));
168 }
165 };169 };
166 field(ui, "Text to display:", text_field(), &mut dialog.text, "");170 field(ui, "Text to display:", text_field(), &mut dialog.text, "");
167 field(ui, "Address:", address_field(), &mut dialog.address, "");171 field(ui, "Address:", address_field(), &mut dialog.address, "");
...@@ -196,6 +200,7 @@ impl State {...@@ -196,6 +200,7 @@ impl State {
196 radius: 4.0,200 radius: 4.0,
197 pad: [4.0, 4.0],201 pad: [4.0, 4.0],
198 flags: Flags::CLIP,202 flags: Flags::CLIP,
203 role: Some(accesskit::Role::List),
199 ..Spec::default()204 ..Spec::default()
200 },205 },
201 );206 );
...@@ -213,6 +218,7 @@ impl State {...@@ -213,6 +218,7 @@ impl State {
213 hover_fill: Some(theme.hover()),218 hover_fill: Some(theme.hover()),
214 radius: 4.0,219 radius: 4.0,
215 pad: [8.0, 0.0],220 pad: [8.0, 0.0],
221 role: Some(accesskit::Role::ListItem),
216 ..Spec::default()222 ..Spec::default()
217 };223 };
218 if ui.leaf(space, spec).clicked {224 if ui.leaf(space, spec).clicked {
...@@ -301,13 +307,52 @@ impl State {...@@ -301,13 +307,52 @@ impl State {
301 text,307 text,
302 ..Item::default()308 ..Item::default()
303 };309 };
304 let items = if context.attachment.is_some() {310 // OneNote 2010 heads the menu on a marked word with its corrections.
311 let mut items = match &context.spelling {
312 Some(correction) if correction.repeated => vec![
313 item("Delete Repeated Word"),
314 item("Ignore"),
315 Item {
316 separated: true,
317 ..item("Spelling…")
318 },
319 ],
320 Some(correction) => {
321 let mut items: Vec<Item> = correction
322 .suggestions
323 .iter()
324 .map(|suggestion| item(suggestion.as_str()))
325 .collect();
326 if items.is_empty() {
327 items.push(Item {
328 disabled: true,
329 ..item("(No Spelling Suggestions)")
330 });
331 }
332 items.extend([
333 Item {
334 separated: true,
335 ..item("Ignore")
336 },
337 item("Add to Dictionary"),
338 Item {
339 separated: true,
340 ..item("Spelling…")
341 },
342 ]);
343 items
344 }
345 None => Vec::new(),
346 };
347 let corrections = items.len();
348 items.extend(if context.attachment.is_some() {
305 // OneNote 2010's commands for the file itself; its clipboard holds text alone.349 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
306 vec![item("Open"), item("Save As…")]350 vec![item("Open"), item("Save As…")]
307 } else {351 } else {
308 let mut items = vec![352 let mut items = vec![
309 Item {353 Item {
310 disabled: !context.selected,354 disabled: !context.selected,
355 separated: corrections > 0,
311 ..item("Cut")356 ..item("Cut")
312 },357 },
313 Item {358 Item {
...@@ -348,16 +393,43 @@ impl State {...@@ -348,16 +393,43 @@ impl State {
348 ]);393 ]);
349 }394 }
350 items395 items
351 };396 });
352 let Some(chosen) =397 let Some(chosen) =
353 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)398 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
354 else {399 else {
355 return Ok(());400 return Ok(());
356 };401 };
402 let chosen_text = items[chosen].text.to_owned();
357 let Some((context, _)) = self.text_menu.take() else {403 let Some((context, _)) = self.text_menu.take() else {
358 return Ok(());404 return Ok(());
359 };405 };
360 let response = match items[chosen].text {406 if chosen < corrections {
407 let correction = context.spelling.unwrap();
408 let response = match chosen_text.as_str() {
409 _ if chosen < correction.suggestions.len() => {
410 self.view.correct(&correction, &chosen_text)?
411 }
412 "Delete Repeated Word" => self.view.correct(&correction, "")?,
413 "Spelling…" => {
414 self.open_spelling_pane();
415 return Ok(());
416 }
417 text => {
418 if let Some(spelling) = &self.view.spelling {
419 if text == "Ignore" {
420 spelling.ignore(&correction.word);
421 } else {
422 spelling.learn(&correction.word);
423 }
424 }
425 self.window.request_redraw();
426 return Ok(());
427 }
428 };
429 self.respond(response);
430 return Ok(());
431 }
432 let response = match chosen_text.as_str() {
361 "Open" => return self.open_attachment(&context.attachment.unwrap()),433 "Open" => return self.open_attachment(&context.attachment.unwrap()),
362 "Save As…" => return self.save_attachment(&context.attachment.unwrap()),434 "Save As…" => return self.save_attachment(&context.attachment.unwrap()),
363 "Cut" => self.view.copy(true)?,435 "Cut" => self.view.copy(true)?,
crates/snowbound/src/linux.rs+91-67
...@@ -18,11 +18,9 @@ use winit::{...@@ -18,11 +18,9 @@ use winit::{
18 error::EventLoopError,18 error::EventLoopError,
19 event_loop::{EventLoop, EventLoopProxy},19 event_loop::{EventLoop, EventLoopProxy},
20 platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland},20 platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland},
21 window::{Icon, ResizeDirection, Theme, Window, WindowAttributes},21 window::{ResizeDirection, Theme, Window, WindowAttributes},
22};22};
2323
24/// The name the desktop entry, the icon theme and window matching know the app by.
25const APP_ID: &str = "snowbound";
26/// The title bar's leading margin; the window controls sit at its trailing end.24/// The title bar's leading margin; the window controls sit at its trailing end.
27pub const LEADING: f32 = 8.0;25pub const LEADING: f32 = 8.0;
28/// The margin past the window controls.26/// The margin past the window controls.
...@@ -40,10 +38,13 @@ static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();...@@ -40,10 +38,13 @@ static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
40/// KWin leaves windows square.38/// KWin leaves windows square.
41static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);39static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);
4240
43pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {41pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
44 // Month and day names and date orders follow the user's locale; other categories stay C.42 // Month and day names and date orders follow the user's locale; other categories stay C.
45 unsafe { libc::setlocale(libc::LC_TIME, c"".as_ptr()) };43 unsafe { libc::setlocale(libc::LC_TIME, c"".as_ptr()) };
46 let event_loop = EventLoop::with_user_event().build()?;44 let event_loop = EventLoop::with_user_event().build()?;
45 if !headless {
46 crate::desktop::prepare(&event_loop);
47 }
47 QUIT.set(event_loop.create_proxy())48 QUIT.set(event_loop.create_proxy())
48 .expect("Only one application event loop is created");49 .expect("Only one application event loop is created");
49 watch_settings(event_loop.create_proxy());50 watch_settings(event_loop.create_proxy());
...@@ -148,15 +149,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {...@@ -148,15 +149,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {
148/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies149/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies
149/// the icon on Wayland, while X11 takes it from the window.150/// the icon on Wayland, while X11 takes it from the window.
150pub fn window_attributes() -> WindowAttributes {151pub fn window_attributes() -> WindowAttributes {
151 let icon = icon_pixels(64).and_then(|(side, rgba)| Icon::from_rgba(rgba, side, side).ok());152 use crate::desktop::APP_ID;
152 Window::default_attributes()153 Window::default_attributes()
153 .with_name(APP_ID, APP_ID)154 .with_name(APP_ID, APP_ID)
154 .with_window_icon(icon)155 .with_window_icon(crate::desktop::window_icon())
155 .with_transparent(cuts_corners())156 .with_transparent(cuts_corners())
156}157}
157158
158/// The window manager or the frame draws the title bar.159/// The window manager or the frame draws the title bar; the icon is the window's own where
159pub fn install_title_bar(_: &Window) {}160/// the compositor allows.
161pub fn install_title_bar(window: &Window) {
162 crate::desktop::set_toplevel_icon(window);
163}
160164
161/// Wayland's clipboard through the window's own connection, as not every compositor offers165/// Wayland's clipboard through the window's own connection, as not every compositor offers
162/// a clipboard to clients without a window; X11's otherwise.166/// a clipboard to clients without a window; X11's otherwise.
...@@ -522,10 +526,20 @@ pub fn window_controls(ui: &mut Ui, window: &Window) {...@@ -522,10 +526,20 @@ pub fn window_controls(ui: &mut Ui, window: &Window) {
522 hover_fill: Some(disc(0.18)),526 hover_fill: Some(disc(0.18)),
523 radius: ui::shell::TOOL / 2.0,527 radius: ui::shell::TOOL / 2.0,
524 center: true,528 center: true,
529 role: Some(accesskit::Role::Button),
525 ..Spec::default()530 ..Spec::default()
526 },531 },
527 )532 )
528 .clicked;533 .clicked;
534 let name = match *control {
535 "minimize" => "Minimize",
536 "maximize" if window.is_maximized() => "Restore",
537 "maximize" => "Maximize",
538 _ => "Close",
539 };
540 if let Some(node) = ui.access(ui.id(control)) {
541 node.set_label(name);
542 }
529 match *control {543 match *control {
530 _ if !clicked => {}544 _ if !clicked => {}
531 "minimize" => window.set_minimized(true),545 "minimize" => window.set_minimized(true),
...@@ -581,11 +595,16 @@ pub fn move_cursor() -> winit::window::CursorIcon {...@@ -581,11 +595,16 @@ pub fn move_cursor() -> winit::window::CursorIcon {
581 winit::window::CursorIcon::Move595 winit::window::CursorIcon::Move
582}596}
583597
584/// Scrollbars overlay the content.598/// No system border lies under the chrome.
585pub fn scrollers() -> Option<ui::Scrollers> {599pub fn cover_border_line(_: &mut Ui, _: f32) {}
586 None600
601pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
602 run()
587}603}
588604
605/// The interface keeps fontconfig's font and scrollbars overlay the content.
606pub fn system_interface(_: &mut Ui) {}
607
589/// Resizing takes the window's edges.608/// Resizing takes the window's edges.
590pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {}609pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {}
591610
...@@ -605,6 +624,11 @@ pub fn configure_presentation(_: &wgpu::Surface<'_>) {}...@@ -605,6 +624,11 @@ pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
605#[cfg(feature = "wgpu")]624#[cfg(feature = "wgpu")]
606pub fn commit_presentation(_: &Window) {}625pub fn commit_presentation(_: &Window) {}
607626
627/// winit reports no tablet pressure here, so every stroke keeps its pen's width.
628pub fn pen_pressure() -> Option<f32> {
629 None
630}
631
608/// GTK's default double-click time.632/// GTK's default double-click time.
609pub fn double_click_interval() -> Duration {633pub fn double_click_interval() -> Duration {
610 Duration::from_millis(400)634 Duration::from_millis(400)
...@@ -766,7 +790,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {...@@ -766,7 +790,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
766pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {790pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
767 let user = mount.user.clone().unwrap_or_default();791 let user = mount.user.clone().unwrap_or_default();
768 let mut lookup = Command::new("secret-tool");792 let mut lookup = Command::new("secret-tool");
769 lookup.args(["lookup", "protocol", "smb", "server", &mount.server]);793 lookup.args(["lookup", "protocol", "smb", "server", mount.host()]);
770 if !user.is_empty() {794 if !user.is_empty() {
771 lookup.args(["user", &user]);795 lookup.args(["user", &user]);
772 }796 }
...@@ -802,72 +826,57 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,...@@ -802,72 +826,57 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
802 })826 })
803}827}
804828
829/// What the sign-in offers for keeping a password, where the Secret Service's tool is
830/// installed to keep it.
831pub fn remember_label() -> Option<&'static str> {
832 let path = std::env::var_os("PATH")?;
833 std::env::split_paths(&path)
834 .any(|folder| folder.join("secret-tool").is_file())
835 .then_some("Remember this password")
836}
837
838/// Keeps `login`'s password for `mount`'s server in the Secret Service, where `smb_login`
839/// and GNOME's file manager look for it.
840pub fn save_login(
841 mount: &crate::library::Mount,
842 login: &crate::library::Login,
843) -> Result<(), String> {
844 use std::io::Write;
845 let label = format!("{} on {}", login.user, mount.host());
846 let mut store = Command::new("secret-tool")
847 .args(["store", "--label", &label, "protocol", "smb"])
848 .args(["server", mount.host(), "user", &login.user])
849 .stdin(std::process::Stdio::piped())
850 .spawn()
851 .map_err(|error| error.to_string())?;
852 store
853 .stdin
854 .take()
855 .ok_or("The Secret Service didn't take the password")?
856 .write_all(login.password.as_bytes())
857 .map_err(|error| error.to_string())?;
858 match store.wait() {
859 Ok(status) if status.success() => Ok(()),
860 _ => Err("The Secret Service didn't keep the password".to_owned()),
861 }
862}
863
805pub fn settings_dir() -> Option<PathBuf> {864pub fn settings_dir() -> Option<PathBuf> {
806 xdg_dir("XDG_CONFIG_HOME", ".config")865 xdg_dir("XDG_CONFIG_HOME", ".config")
807}866}
808867
809/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.868/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
810fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {869fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
811 Some(xdg_base(variable, fallback)?.join(APP_ID))870 Some(xdg_base(variable, fallback)?.join("snowbound"))
812}871}
813872
814fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {873pub(crate) fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {
815 std::env::var_os(variable)874 std::env::var_os(variable)
816 .map(PathBuf::from)875 .map(PathBuf::from)
817 .filter(|path| path.is_absolute())876 .filter(|path| path.is_absolute())
818 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))877 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
819}878}
820879
821/// Looks beside the executable first, as the release archive installs it, then in the
822/// XDG data directories.
823fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> {
824 let home = std::env::var_os("XDG_DATA_HOME")
825 .map(PathBuf::from)
826 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")));
827 let system = std::env::var("XDG_DATA_DIRS")
828 .ok()
829 .filter(|dirs| !dirs.is_empty())
830 .unwrap_or_else(|| "/usr/local/share:/usr/share".into());
831 let roots: Vec<PathBuf> = std::env::current_exe()
832 .ok()
833 .and_then(|exe| Some(exe.parent()?.parent()?.join("share")))
834 .into_iter()
835 .chain(home)
836 .chain(std::env::split_paths(&system))
837 .collect();
838 let sizes = [16, 24, 32, 48, 64, 128, 256, 512];
839 let ordered = sizes
840 .iter()
841 .filter(|&&side| side >= pixels)
842 .chain(sizes.iter().rev().filter(|&&side| side < pixels));
843 ordered
844 .flat_map(|side| {
845 roots.iter().map(move |root| {
846 root.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))
847 })
848 })
849 .find_map(|path| decode_rgba(&path))
850}
851
852fn decode_rgba(path: &std::path::Path) -> Option<(u32, Vec<u8>)> {
853 let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
854 let mut decoder = png::Decoder::new(file);
855 decoder.set_transformations(png::Transformations::normalize_to_color8());
856 let mut reader = decoder.read_info().ok()?;
857 let mut pixels = vec![0; reader.output_buffer_size()?];
858 let frame = reader.next_frame(&mut pixels).ok()?;
859 pixels.truncate(frame.buffer_size());
860 let rgba = match frame.color_type {
861 png::ColorType::Rgba => pixels,
862 png::ColorType::Rgb => pixels
863 .chunks_exact(3)
864 .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255])
865 .collect(),
866 _ => return None,
867 };
868 (frame.width == frame.height).then_some((frame.width, rgba))
869}
870
871/// The account's full name from its passwd entry, or its login where that has none.880/// The account's full name from its passwd entry, or its login where that has none.
872pub fn user_name() -> String {881pub fn user_name() -> String {
873 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };882 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
...@@ -1170,7 +1179,12 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {...@@ -1170,7 +1179,12 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {
1170}1179}
11711180
1172/// Asks where to put something named `name` by default; the dialog names its own button.1181/// Asks where to put something named `name` by default; the dialog names its own button.
1173pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> {1182pub fn pick_new(
1183 title: &str,
1184 name: &str,
1185 _action: &str,
1186 _folder: Option<&std::path::Path>,
1187) -> Option<PathBuf> {
1174 let asked = dialog(1188 let asked = dialog(
1175 [1189 [
1176 "--file-selection",1190 "--file-selection",
...@@ -1191,18 +1205,28 @@ pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> {...@@ -1191,18 +1205,28 @@ pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> {
11911205
1192/// Tells the user something they asked for could not be done: `message`, then what to do.1206/// Tells the user something they asked for could not be done: `message`, then what to do.
1193pub fn alert(message: &str, detail: &str) {1207pub fn alert(message: &str, detail: &str) {
1208 show(["--warning", "--sorry"], message, detail);
1209}
1210
1211/// Tells the user how something they asked for turned out.
1212pub fn inform(message: &str, detail: &str) {
1213 show(["--info", "--msgbox"], message, detail);
1214}
1215
1216/// Shows `message` and `detail` in zenity's or else kdialog's dialog of the `kinds`.
1217fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
1194 let (title, text) = (message.to_owned(), detail.to_owned());1218 let (title, text) = (message.to_owned(), detail.to_owned());
1195 std::thread::spawn(move || {1219 std::thread::spawn(move || {
1196 let shown = Command::new("zenity")1220 let shown = Command::new("zenity")
1197 .args([1221 .args([
1198 "--warning",1222 zenity,
1199 &format!("--title={title}"),1223 &format!("--title={title}"),
1200 &format!("--text={text}"),1224 &format!("--text={text}"),
1201 ])1225 ])
1202 .status()1226 .status()
1203 .or_else(|_| {1227 .or_else(|_| {
1204 Command::new("kdialog")1228 Command::new("kdialog")
1205 .args(["--sorry", &text, "--title", &title])1229 .args([kdialog, &text, "--title", &title])
1206 .status()1230 .status()
1207 });1231 });
1208 if shown.is_err() {1232 if shown.is_err() {
crates/snowbound/src/macos.rs+228-15
...@@ -10,8 +10,8 @@ use objc2::{...@@ -10,8 +10,8 @@ use objc2::{
10};10};
11use objc2_app_kit::{11use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,12 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType,13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSMenu, NSMenuItem,14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem,
15};15};
16use objc2_foundation::{16use objc2_foundation::{
17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
...@@ -29,7 +29,13 @@ use winit::{...@@ -29,7 +29,13 @@ use winit::{
29 window::{Window, WindowAttributes},29 window::{Window, WindowAttributes},
30};30};
3131
32pub use crate::aqua::{move_cursor, resize_grip, scrollers};32/// Runs `run` inside an autorelease pool. 10.6 has none outside NSApplication's run loop,
33/// and quitting releases the windows after it returns.
34pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
35 objc2::rc::autoreleasepool(|_| run())
36}
37
38pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface};
3339
34static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();40static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
35static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();41static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
...@@ -123,7 +129,7 @@ pub fn install_title_bar(window: &Window) {...@@ -123,7 +129,7 @@ pub fn install_title_bar(window: &Window) {
123 let responds =129 let responds =
124 |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } };130 |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } };
125 // Before 10.10 the title bar keeps its own line above the row.131 // Before 10.10 the title bar keeps its own line above the row.
126 if !responds(sel!(setTitleVisibility:)) {132 if crate::aqua::before_lion() || !responds(sel!(setTitleVisibility:)) {
127 return;133 return;
128 }134 }
129 unsafe {135 unsafe {
...@@ -187,6 +193,9 @@ pub fn window_controls(_: &mut ui::Ui, _: &Window) {}...@@ -187,6 +193,9 @@ pub fn window_controls(_: &mut ui::Ui, _: &Window) {}
187/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and193/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and
188/// otherwise the app draws it around the traffic lights.194/// otherwise the app draws it around the traffic lights.
189pub fn system_titlebar(window: &Window) -> bool {195pub fn system_titlebar(window: &Window) -> bool {
196 if crate::aqua::before_lion() {
197 return true;
198 }
190 let window = ns_window(window);199 let window = ns_window(window);
191 unsafe {200 unsafe {
192 let frame: NSRect = msg_send![&window, frame];201 let frame: NSRect = msg_send![&window, frame];
...@@ -358,22 +367,150 @@ unsafe extern "C" {...@@ -358,22 +367,150 @@ unsafe extern "C" {
358 list: *const std::ffi::c_void,367 list: *const std::ffi::c_void,
359 data: *mut std::ffi::c_void,368 data: *mut std::ffi::c_void,
360 ) -> i32;369 ) -> i32;
370 fn SecKeychainAddInternetPassword(
371 keychain: *const std::ffi::c_void,
372 server_length: u32,
373 server: *const u8,
374 domain_length: u32,
375 domain: *const u8,
376 account_length: u32,
377 account: *const u8,
378 path_length: u32,
379 path: *const u8,
380 port: u16,
381 protocol: u32,
382 authentication: u32,
383 password_length: u32,
384 password: *const u8,
385 item: *mut *const std::ffi::c_void,
386 ) -> i32;
387 fn SecKeychainItemModifyAttributesAndData(
388 item: *const std::ffi::c_void,
389 attributes: *const std::ffi::c_void,
390 length: u32,
391 data: *const u8,
392 ) -> i32;
393}
394
395#[link(name = "CoreFoundation", kind = "framework")]
396unsafe extern "C" {
397 fn CFRelease(object: *const std::ffi::c_void);
398}
399
400/// kSecProtocolTypeSMB, as the Finder keeps an SMB server's passwords.
401const SMB: u32 = u32::from_be_bytes(*b"smb ");
402/// errSecDuplicateItem.
403const DUPLICATE: i32 = -25299;
404
405/// What the sign-in offers for keeping a password, which the keychain keeps.
406pub fn remember_label() -> Option<&'static str> {
407 Some("Remember this password in my keychain")
408}
409
410/// Keeps `login`'s password for `mount`'s server in the default keychain, as the Finder keeps
411/// one, replacing any kept for the account before.
412pub fn save_login(
413 mount: &crate::library::Mount,
414 login: &crate::library::Login,
415) -> Result<(), String> {
416 save_login_in(std::ptr::null(), mount, login)
417}
418
419fn save_login_in(
420 keychain: *const std::ffi::c_void,
421 mount: &crate::library::Mount,
422 login: &crate::library::Login,
423) -> Result<(), String> {
424 let (server, user, password) = (mount.host(), &login.user, &login.password);
425 let port = match mount.host() == mount.server {
426 true => 0,
427 false => mount.server[server.len() + 1..].parse().unwrap_or(0),
428 };
429 let status = unsafe {
430 SecKeychainAddInternetPassword(
431 keychain,
432 server.len() as u32,
433 server.as_ptr(),
434 0,
435 std::ptr::null(),
436 user.len() as u32,
437 user.as_ptr(),
438 0,
439 std::ptr::null(),
440 port,
441 SMB,
442 u32::from_be_bytes(*b"dflt"),
443 password.len() as u32,
444 password.as_ptr(),
445 std::ptr::null_mut(),
446 )
447 };
448 let status = match status {
449 DUPLICATE => {
450 let mut item = std::ptr::null();
451 let found = unsafe {
452 SecKeychainFindInternetPassword(
453 keychain,
454 server.len() as u32,
455 server.as_ptr(),
456 0,
457 std::ptr::null(),
458 user.len() as u32,
459 user.as_ptr(),
460 0,
461 std::ptr::null(),
462 port,
463 SMB,
464 0,
465 std::ptr::null_mut(),
466 std::ptr::null_mut(),
467 &mut item,
468 )
469 };
470 if found != 0 {
471 found
472 } else {
473 let status = unsafe {
474 SecKeychainItemModifyAttributesAndData(
475 item,
476 std::ptr::null(),
477 password.len() as u32,
478 password.as_ptr(),
479 )
480 };
481 unsafe { CFRelease(item) };
482 status
483 }
484 }
485 status => status,
486 };
487 match status {
488 0 => Ok(()),
489 status => Err(format!("The keychain didn't keep the password ({status})")),
490 }
361}491}
362492
363/// The password the keychain keeps for `mount`'s account on its server, as macOS saved it493/// The password the keychain keeps for `mount`'s account on its server, as macOS saved it
364/// when the share was mounted; the system asks the user to allow the app to read it.494/// when the share was mounted or Snowbound when it signed in; the system asks the user to
495/// allow the app to read it.
365pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {496pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
497 smb_login_in(std::ptr::null(), mount)
498}
499
500fn smb_login_in(
501 keychain: *const std::ffi::c_void,
502 mount: &crate::library::Mount,
503) -> Result<crate::library::Login, String> {
366 let Some(user) = &mount.user else {504 let Some(user) = &mount.user else {
367 return Ok(crate::library::Login::guest(mount));505 return Ok(crate::library::Login::guest(mount));
368 };506 };
369 // kSecProtocolTypeSMB is 'smb '.507 let server = mount.host();
370 const SMB: u32 = u32::from_be_bytes(*b"smb ");
371 let (mut length, mut data) = (0, std::ptr::null_mut());508 let (mut length, mut data) = (0, std::ptr::null_mut());
372 let status = unsafe {509 let status = unsafe {
373 SecKeychainFindInternetPassword(510 SecKeychainFindInternetPassword(
374 std::ptr::null(),511 keychain,
375 mount.server.len() as u32,512 server.len() as u32,
376 mount.server.as_ptr(),513 server.as_ptr(),
377 0,514 0,
378 std::ptr::null(),515 std::ptr::null(),
379 user.len() as u32,516 user.len() as u32,
...@@ -390,8 +527,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,...@@ -390,8 +527,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
390 };527 };
391 if status != 0 {528 if status != 0 {
392 return Err(format!(529 return Err(format!(
393 "The keychain has no password for {user} on {} ({status})",530 "Enter the password for \u{201c}{user}\u{201d} on \u{201c}{server}\u{201d}."
394 mount.server
395 ));531 ));
396 }532 }
397 let password = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), length as usize) };533 let password = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), length as usize) };
...@@ -430,11 +566,22 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {...@@ -430,11 +566,22 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
430 }566 }
431}567}
432568
433/// Asks where to `action` something named `name` by default, with the system's save panel.569/// Asks where to `action` something named `name` by default, with the system's save panel,
434pub fn pick_new(title: &str, name: &str, action: &str) -> Option<std::path::PathBuf> {570/// starting in `folder` where given.
571pub fn pick_new(
572 title: &str,
573 name: &str,
574 action: &str,
575 folder: Option<&std::path::Path>,
576) -> Option<std::path::PathBuf> {
435 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");577 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
436 unsafe {578 unsafe {
437 let panel = objc2_app_kit::NSSavePanel::savePanel(mtm);579 let panel = objc2_app_kit::NSSavePanel::savePanel(mtm);
580 if let Some(folder) = folder.and_then(std::path::Path::to_str) {
581 panel.setDirectoryURL(Some(&objc2_foundation::NSURL::fileURLWithPath(
582 &NSString::from_str(folder),
583 )));
584 }
438 panel.setCanCreateDirectories(true);585 panel.setCanCreateDirectories(true);
439 panel.setTitle(Some(&NSString::from_str(title)));586 panel.setTitle(Some(&NSString::from_str(title)));
440 panel.setPrompt(Some(&NSString::from_str(action)));587 panel.setPrompt(Some(&NSString::from_str(action)));
...@@ -635,6 +782,18 @@ pub fn install_text_input(window: &Window) {...@@ -635,6 +782,18 @@ pub fn install_text_input(window: &Window) {
635 }782 }
636}783}
637784
785/// A tablet pen's pressure in the event AppKit is delivering, from 0 to 1; none for a mouse
786/// or trackpad. winit reports only a trackpad's Force Touch, so the pen's comes from here.
787pub fn pen_pressure() -> Option<f32> {
788 let mtm = MainThreadMarker::new()?;
789 let event = NSApplication::sharedApplication(mtm).currentEvent()?;
790 let tablet = matches!(
791 unsafe { event.r#type() },
792 NSEventType::LeftMouseDown | NSEventType::LeftMouseDragged | NSEventType::LeftMouseUp
793 ) && unsafe { event.subtype() } == NSEventSubtype::TabletPoint;
794 tablet.then(|| unsafe { event.pressure() })
795}
796
638pub fn double_click_interval() -> std::time::Duration {797pub fn double_click_interval() -> std::time::Duration {
639 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })798 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })
640}799}
...@@ -934,7 +1093,7 @@ declare_class!(...@@ -934,7 +1093,7 @@ declare_class!(
934 let status = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| {1093 let status = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| {
935 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())1094 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
936 }).unwrap_or_default();1095 }).unwrap_or_default();
937 unsafe { item.setState(isize::from(status.checked)) };1096 unsafe { item.setState(isize::from(status.checked == Some(true))) };
938 objc2::runtime::Bool::new(status.enabled)1097 objc2::runtime::Bool::new(status.enabled)
939 }1098 }
9401099
...@@ -1082,3 +1241,57 @@ pub fn input_language() -> String {...@@ -1082,3 +1241,57 @@ pub fn input_language() -> String {
1082 language.map_or_else(String::new, |language| language.to_string())1241 language.map_or_else(String::new, |language| language.to_string())
1083 }1242 }
1084}1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use crate::library::{Login, Mount};
1248
1249 #[link(name = "Security", kind = "framework")]
1250 unsafe extern "C" {
1251 fn SecKeychainCreate(
1252 path: *const std::ffi::c_char,
1253 password_length: u32,
1254 password: *const u8,
1255 prompt: bool,
1256 access: *const std::ffi::c_void,
1257 keychain: *mut *const std::ffi::c_void,
1258 ) -> i32;
1259 fn SecKeychainDelete(keychain: *const std::ffi::c_void) -> i32;
1260 }
1261
1262 /// A kept password reads back for the server whatever its port, and keeping another
1263 /// replaces it; in a keychain of the test's own, never the user's.
1264 #[test]
1265 #[ignore = "a rebuilt test binary may make the system ask on screen to allow keychain access"]
1266 fn logins_kept_in_a_keychain_read_back() {
1267 let path = std::env::temp_dir().join(format!("snowbound-{}.keychain", std::process::id()));
1268 let _ = std::fs::remove_file(&path);
1269 let name = std::ffi::CString::new(path.to_string_lossy().as_bytes()).unwrap();
1270 let mut keychain = std::ptr::null();
1271 let lock = b"test keychain";
1272 let status = unsafe {
1273 SecKeychainCreate(
1274 name.as_ptr(),
1275 lock.len() as u32,
1276 lock.as_ptr(),
1277 false,
1278 std::ptr::null(),
1279 &mut keychain,
1280 )
1281 };
1282 assert_eq!(status, 0);
1283 let mount = Mount::from_address("smb://amy@nas.local:1445/notes").unwrap();
1284 let login = |password: &str| Login {
1285 user: "amy".into(),
1286 password: password.into(),
1287 domain: String::new(),
1288 };
1289 assert!(super::smb_login_in(keychain, &mount).is_err());
1290 super::save_login_in(keychain, &mount, &login("first")).unwrap();
1291 super::save_login_in(keychain, &mount, &login("second")).unwrap();
1292 let read = super::smb_login_in(keychain, &mount);
1293 unsafe { SecKeychainDelete(keychain) };
1294 let _ = std::fs::remove_file(&path);
1295 assert_eq!(read.unwrap().password, "second");
1296 }
1297}
crates/snowbound/src/main.rs+722-157
...@@ -4,9 +4,15 @@ mod art;...@@ -4,9 +4,15 @@ mod art;
4mod attachment;4mod attachment;
5mod background;5mod background;
6mod commands;6mod commands;
7#[cfg(test)]7#[cfg(all(test, feature = "wgpu"))]
8mod conflict_render;8mod conflict_render;
9#[cfg(target_os = "linux")]
10#[path = "desktop_linux.rs"]
11mod desktop;
9mod history;12mod history;
13#[cfg_attr(target_os = "linux", path = "icloud_linux.rs")]
14#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
15mod icloud;
10mod library;16mod library;
11mod link;17mod link;
12mod manage;18mod manage;
...@@ -28,16 +34,22 @@ mod recording;...@@ -28,16 +34,22 @@ mod recording;
28mod rename;34mod rename;
29mod screenshot;35mod screenshot;
30mod search;36mod search;
37mod server;
31mod settings;38mod settings;
32mod sidebar;39mod sidebar;
40#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]
41#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]
42mod spell;
33#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]43#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
34mod surface;44mod surface;
35mod sync;45mod sync;
36mod tags;46mod tags;
37mod templates;47mod templates;
48mod update;
38mod video;49mod video;
39mod watch;50mod watch;
4051
52use canvas::gpu::colorref;
41use canvas::gpu::page::PageScene;53use canvas::gpu::page::PageScene;
42use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};54use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
43use canvas::{55use canvas::{
...@@ -46,10 +58,6 @@ use canvas::{...@@ -46,10 +58,6 @@ use canvas::{
46 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},58 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},
47 layout::TextEngine,59 layout::TextEngine,
48};60};
49use canvas::{
50 gpu::{colorref, tag_sources},
51 outline::TagIcon,
52};
53use draw::Renderer;61use draw::Renderer;
54use library::Library;62use library::Library;
55use onestore::ExGuid;63use onestore::ExGuid;
...@@ -116,11 +124,28 @@ const HIGHLIGHTS: [(u32, &str); 15] = [...@@ -116,11 +124,28 @@ const HIGHLIGHTS: [(u32, &str); 15] = [
116 (0xc0c0c0, "Gray 25%"),124 (0xc0c0c0, "Gray 25%"),
117 (0x000000, "Black"),125 (0x000000, "Black"),
118];126];
119/// Office's theme and standard font colours, COLORREF.127/// Office's theme and standard font colours, COLORREF, with the names their tooltips give.
120const FONT_COLORS: [u32; 20] = [128const FONT_COLORS: [(u32, &str); 20] = [
121 0xffffff, 0x000000, 0xe1ecee, 0x7d491f, 0xbd814f, 0x4d50c0, 0x59bb9b, 0xa26480, 0xc6ac4b,129 (0xffffff, "White, Background 1"),
122 0x4696f7, 0x0000c0, 0x0000ff, 0x00c0ff, 0x00ffff, 0x50d092, 0x50b000, 0xf0b000, 0xc07000,130 (0x000000, "Black, Text 1"),
123 0x602000, 0xa03070,131 (0xe1ecee, "Tan, Background 2"),
132 (0x7d491f, "Dark Blue, Text 2"),
133 (0xbd814f, "Blue, Accent 1"),
134 (0x4d50c0, "Red, Accent 2"),
135 (0x59bb9b, "Olive Green, Accent 3"),
136 (0xa26480, "Purple, Accent 4"),
137 (0xc6ac4b, "Aqua, Accent 5"),
138 (0x4696f7, "Orange, Accent 6"),
139 (0x0000c0, "Dark Red"),
140 (0x0000ff, "Red"),
141 (0x00c0ff, "Orange"),
142 (0x00ffff, "Yellow"),
143 (0x50d092, "Light Green"),
144 (0x50b000, "Green"),
145 (0xf0b000, "Light Blue"),
146 (0xc07000, "Blue"),
147 (0x602000, "Dark Blue"),
148 (0xa03070, "Purple"),
124];149];
125/// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart150/// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart
126/// while the gaps still take the pointer.151/// while the gaps still take the pointer.
...@@ -144,6 +169,8 @@ enum UserEvent {...@@ -144,6 +169,8 @@ enum UserEvent {
144 Accessibility(accesskit_winit::Event),169 Accessibility(accesskit_winit::Event),
145 /// The section's synchronization thread reported an event.170 /// The section's synchronization thread reported an event.
146 Sync,171 Sync,
172 /// An update check moved on.
173 Update,
147 /// Scripted input from `SNOWBOUND_REPLAY`.174 /// Scripted input from `SNOWBOUND_REPLAY`.
148 Replay(Replay),175 Replay(Replay),
149 /// A page background finished rasterizing on its worker thread.176 /// A page background finished rasterizing on its worker thread.
...@@ -154,11 +181,36 @@ enum UserEvent {...@@ -154,11 +181,36 @@ enum UserEvent {
154 /// The desktop's colours changed, where the window system doesn't say so itself.181 /// The desktop's colours changed, where the window system doesn't say so itself.
155 #[cfg_attr(target_os = "macos", allow(dead_code))]182 #[cfg_attr(target_os = "macos", allow(dead_code))]
156 Appearance,183 Appearance,
184 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.
185 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
186 ICloudAccount,
157}187}
158188
159/// Asks the event loop for a frame from any thread.189/// Asks the event loop for a frame from any thread.
160struct Redraw(EventLoopProxy<UserEvent>);190struct Redraw(EventLoopProxy<UserEvent>);
161191
192enum Clipboard {
193 System(platform::Clipboard),
194 Memory(String),
195}
196
197impl Clipboard {
198 fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {
199 match self {
200 Self::System(clipboard) => clipboard.set_text(text)?,
201 Self::Memory(held) => *held = text,
202 }
203 Ok(())
204 }
205
206 fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
207 Ok(match self {
208 Self::System(clipboard) => clipboard.get_text()?,
209 Self::Memory(held) => held.clone(),
210 })
211 }
212}
213
162impl std::task::Wake for Redraw {214impl std::task::Wake for Redraw {
163 fn wake(self: Arc<Self>) {215 fn wake(self: Arc<Self>) {
164 let _ = self.0.send_event(UserEvent::Redraw);216 let _ = self.0.send_event(UserEvent::Redraw);
...@@ -170,9 +222,13 @@ enum Replay {...@@ -170,9 +222,13 @@ enum Replay {
170 Input(ui::Event),222 Input(ui::Event),
171 /// Paints the next frame into a PNG as well as the window.223 /// Paints the next frame into a PNG as well as the window.
172 Snapshot(PathBuf),224 Snapshot(PathBuf),
225 /// Writes the window's accessibility tree as text.
226 Accessibility(PathBuf),
173 /// A frame during a wait, as a visible window's display would ask for.227 /// A frame during a wait, as a visible window's display would ask for.
174 Tick,228 Tick,
175 Appearance(winit::window::Theme),229 Appearance(winit::window::Theme),
230 /// Resizes the window's content, in points.
231 Resize([f32; 2]),
176 Quit,232 Quit,
177}233}
178234
...@@ -423,10 +479,20 @@ const HOLD: std::time::Duration = std::time::Duration::from_millis(200);...@@ -423,10 +479,20 @@ const HOLD: std::time::Duration = std::time::Duration::from_millis(200);
423enum Command {479enum Command {
424 /// Opens a notebook's section by catalog path, at the page it showed last.480 /// Opens a notebook's section by catalog path, at the page it showed last.
425 OpenSection(Arc<Library>, String),481 OpenSection(Arc<Library>, String),
482 /// Asks for a picture for the tag New Tag or Modify Tag edits.
483 TagPicture,
426 /// Asks for a notebook folder and opens it.484 /// Asks for a notebook folder and opens it.
427 OpenNotebook,485 OpenNotebook,
486 /// Asks for a server's address and opens a notebook on it; with a notebook's location,
487 /// signs in again to open it.
488 OpenFromServer(Option<String>),
428 /// Asks where to keep a new notebook and creates it.489 /// Asks where to keep a new notebook and creates it.
429 NewNotebook,490 NewNotebook,
491 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.
492 UseICloud,
493 /// Adds Snowbound to the app menu.
494 #[cfg(target_os = "linux")]
495 Install,
430 /// Closes a notebook, keeping its files.496 /// Closes a notebook, keeping its files.
431 CloseNotebook(Arc<Library>),497 CloseNotebook(Arc<Library>),
432 /// Changes a notebook's sections and groups.498 /// Changes a notebook's sections and groups.
...@@ -532,8 +598,11 @@ struct State {...@@ -532,8 +598,11 @@ struct State {
532 menu: Option<(menus::Target, [f32; 2])>,598 menu: Option<(menus::Target, [f32; 2])>,
533 /// The Options dialog's choices while it is open.599 /// The Options dialog's choices while it is open.
534 options: Option<options::Options>,600 options: Option<options::Options>,
601 updates: update::Updates,
535 /// The Link dialog's fields while it is open.602 /// The Link dialog's fields while it is open.
536 link: Option<link::LinkDialog>,603 link: Option<link::LinkDialog>,
604 /// Open Notebook from Server while it is open.
605 server: Option<server::Connect>,
537 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.606 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
538 tags: Vec<canvas::editor::NoteTag>,607 tags: Vec<canvas::editor::NoteTag>,
539 /// The Customize Tags dialog's list while it is open.608 /// The Customize Tags dialog's list while it is open.
...@@ -542,6 +611,11 @@ struct State {...@@ -542,6 +611,11 @@ struct State {
542 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,611 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,
543 color_scheme: settings::ColorScheme,612 color_scheme: settings::ColorScheme,
544 light_pages: bool,613 light_pages: bool,
614 /// The system's spell checker, where it has one.
615 spelling: Option<canvas::spelling::Spelling>,
616 hide_spelling: bool,
617 /// The word the Spelling pane shows.
618 correction: Option<canvas::interaction::Correction>,
545 /// The strip's fill with the window focused and not, continuing the system's title bar.619 /// The strip's fill with the window focused and not, continuing the system's title bar.
546 titlebar: [[f32; 4]; 2],620 titlebar: [[f32; 4]; 2],
547 /// A section or group being renamed in the sidebar.621 /// A section or group being renamed in the sidebar.
...@@ -600,11 +674,16 @@ struct State {...@@ -600,11 +674,16 @@ struct State {
600 initial_date: Option<u64>,674 initial_date: Option<u64>,
601 occluded: bool,675 occluded: bool,
602 ime_allowed: bool,676 ime_allowed: bool,
603 clipboard: platform::Clipboard,677 clipboard: Clipboard,
604 access_adapter: accesskit_winit::Adapter,678 access_adapter: accesskit_winit::Adapter,
605 accessibility: accessibility::Accessibility,679 accessibility: accessibility::Accessibility,
680 /// Assistive technology holds the page's tree, grafted into the interface's.
681 page_grafted: bool,
606}682}
607683
684/// The page's accessibility tree, which the interface's holds at the page's box.
685const PAGE_TREE: accesskit::TreeId = accesskit::TreeId(accesskit::Uuid::from_u128(1));
686
608fn strip() -> Id {687fn strip() -> Id {
609 Id::ROOT.child("strip")688 Id::ROOT.child("strip")
610}689}
...@@ -614,6 +693,8 @@ fn tab_row() -> Id {...@@ -614,6 +693,8 @@ fn tab_row() -> Id {
614 Id::ROOT.child("tab row")693 Id::ROOT.child("tab row")
615}694}
616695
696/// The page's box, only ever built as the graft of the page's accessibility tree, so the
697/// tree is sent while it is laid out.
617fn page() -> Id {698fn page() -> Id {
618 Id::ROOT.child("page")699 Id::ROOT.child("page")
619}700}
...@@ -683,6 +764,11 @@ impl State {...@@ -683,6 +764,11 @@ impl State {
683 library::on_background(move || {764 library::on_background(move || {
684 let _ = background.send_event(UserEvent::Sync);765 let _ = background.send_event(UserEvent::Sync);
685 });766 });
767 let account = proxy.clone();
768 icloud::on_account_change(move || {
769 let _ = account.send_event(UserEvent::ICloudAccount);
770 });
771 icloud::look_up(notify(proxy.clone()));
686 let mut notebooks = Vec::new();772 let mut notebooks = Vec::new();
687 let mut session = None;773 let mut session = None;
688 let mut sectionless = None;774 let mut sectionless = None;
...@@ -785,7 +871,7 @@ impl State {...@@ -785,7 +871,7 @@ impl State {
785 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);871 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);
786 // A window shown but never focused hears no focus event; a hidden one draws as focused.872 // A window shown but never focused hears no focus event; a hidden one draws as focused.
787 ui.window_focused = !visible || window.has_focus();873 ui.window_focused = !visible || window.has_focus();
788 ui.scrollers = platform::scrollers();874 platform::system_interface(&mut ui);
789 ui.set_focus(Some(page()));875 ui.set_focus(Some(page()));
790 for family in FONTS {876 for family in FONTS {
791 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {877 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
...@@ -802,10 +888,18 @@ impl State {...@@ -802,10 +888,18 @@ impl State {
802 .collect();888 .collect();
803 fonts.sort_unstable_by_key(|name| name.to_lowercase());889 fonts.sort_unstable_by_key(|name| name.to_lowercase());
804 fonts.dedup();890 fonts.dedup();
805 let clipboard = platform::Clipboard::new(&window)?;891 // A replay's or hidden window's copies leave the user's clipboard alone.
892 let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() {
893 Clipboard::System(platform::Clipboard::new(&window)?)
894 } else {
895 Clipboard::Memory(String::new())
896 };
806 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();897 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();
807 let search = search::Search::new(stored.search_scope, redraw.clone());898 let search = search::Search::new(stored.search_scope, redraw.clone());
808 let state = Self {899 let spelling = spell::dictionary()
900 .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone()));
901 let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone());
902 let mut state = Self {
809 author: stored.user_name.unwrap_or_else(platform::user_name),903 author: stored.user_name.unwrap_or_else(platform::user_name),
810 window,904 window,
811 redraw,905 redraw,
...@@ -832,12 +926,14 @@ impl State {...@@ -832,12 +926,14 @@ impl State {
832 title_focus: None,926 title_focus: None,
833 menu: None,927 menu: None,
834 link: None,928 link: None,
929 server: None,
835 tags: stored930 tags: stored
836 .tags931 .tags
837 .unwrap_or_else(canvas::editor::NoteTag::defaults),932 .unwrap_or_else(canvas::editor::NoteTag::defaults),
838 tag_list: None,933 tag_list: None,
839 text_menu: None,934 text_menu: None,
840 options: None,935 options: None,
936 updates,
841 color_scheme: stored.color_scheme,937 color_scheme: stored.color_scheme,
842 light_pages: stored.light_pages,938 light_pages: stored.light_pages,
843 titlebar,939 titlebar,
...@@ -881,7 +977,20 @@ impl State {...@@ -881,7 +977,20 @@ impl State {
881 swipe: navigation::Swipe::default(),977 swipe: navigation::Swipe::default(),
882 access_adapter,978 access_adapter,
883 accessibility: accessibility::Accessibility::default(),979 accessibility: accessibility::Accessibility::default(),
980 page_grafted: false,
981 spelling,
982 hide_spelling: stored.hide_spelling,
983 correction: None,
884 };984 };
985 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
986 let unsigned = state.notebooks.iter().find(|library| {
987 library.notebook.is_err() && library::server_address(&library.location).is_some()
988 });
989 if let Some(location) = unsigned.map(|library| library.location.clone()) {
990 state.commands.push(Command::OpenFromServer(Some(location)));
991 }
992 state.visited();
993 state.show_spelling();
885 state.title();994 state.title();
886 platform::update_tag_menu(&state.tags);995 platform::update_tag_menu(&state.tags);
887 Ok(state)996 Ok(state)
...@@ -906,6 +1015,7 @@ impl State {...@@ -906,6 +1015,7 @@ impl State {
906 [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32),1015 [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32),
907 scale,1016 scale,
908 )?;1017 )?;
1018 self.update_accessibility(false)?;
909 lap("build", start);1019 lap("build", start);
910 self.window.set_cursor(1020 self.window.set_cursor(
911 platform::resize_direction(&self.window, self.pointer)1021 platform::resize_direction(&self.window, self.pointer)
...@@ -951,8 +1061,10 @@ impl State {...@@ -951,8 +1061,10 @@ impl State {
951 /// its box.1061 /// its box.
952 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {1062 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
953 self.ui.begin(size, scale, Instant::now());1063 self.ui.begin(size, scale, Instant::now());
1064 platform::cover_border_line(&mut self.ui, size[0]);
954 let (section, open_tab, open_page) = self.build()?;1065 let (section, open_tab, open_page) = self.build()?;
955 self.options_dialog();1066 self.options_dialog();
1067 self.server_dialog();
956 self.link_dialog()?;1068 self.link_dialog()?;
957 self.customize_tags();1069 self.customize_tags();
958 self.palette();1070 self.palette();
...@@ -1151,6 +1263,7 @@ impl State {...@@ -1151,6 +1263,7 @@ impl State {
1151 TAB_ROW,1263 TAB_ROW,
1152 theme.strip,1264 theme.strip,
1153 );1265 );
1266 name(&mut self.ui, row, "Sections");
1154 self.drag_tabs(held, slot, settled, row);1267 self.drag_tabs(held, slot, settled, row);
1155 let Some(session) = &self.session else {1268 let Some(session) = &self.session else {
1156 unreachable!("The tabs are the session's")1269 unreachable!("The tabs are the session's")
...@@ -1268,9 +1381,13 @@ impl State {...@@ -1268,9 +1381,13 @@ impl State {
1268 flags: Flags::CUSTOM | Flags::FOCUSABLE,1381 flags: Flags::CUSTOM | Flags::FOCUSABLE,
1269 size: [fill(), fill()],1382 size: [fill(), fill()],
1270 fill: Some(theme.paper),1383 fill: Some(theme.paper),
1384 role: Some(accesskit::Role::GenericContainer),
1271 ..Spec::default()1385 ..Spec::default()
1272 },1386 },
1273 );1387 );
1388 if let Some(node) = self.ui.access(page()) {
1389 node.set_tree_id(PAGE_TREE);
1390 }
1274 self.template_strip(&theme);1391 self.template_strip(&theme);
1275 if let Some(rect) = self.ui.rect(page()) {1392 if let Some(rect) = self.ui.rect(page()) {
1276 self.transport(&theme, rect)?;1393 self.transport(&theme, rect)?;
...@@ -1486,6 +1603,7 @@ impl State {...@@ -1486,6 +1603,7 @@ impl State {
1486 fill: Some(theme.strip),1603 fill: Some(theme.strip),
1487 pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],1604 pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],
1488 gap: GAP,1605 gap: GAP,
1606 role: Some(accesskit::Role::Toolbar),
1489 ..Spec::default()1607 ..Spec::default()
1490 };1608 };
1491 if strip_row {1609 if strip_row {
...@@ -1529,7 +1647,7 @@ impl State {...@@ -1529,7 +1647,7 @@ impl State {
1529 /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in1647 /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in
1530 /// taking notes fold last, and those with chords or a place in the menu bar first.1648 /// taking notes fold last, and those with chords or a place in the menu bar first.
1531 fn tools(&mut self, theme: &Theme) {1649 fn tools(&mut self, theme: &Theme) {
1532 use Entry::{Open, Rule, Run, Soon};1650 use Entry::{Open, Rule, Run};
1533 use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle};1651 use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle};
1534 use commands::{Choice, Id as Cmd};1652 use commands::{Choice, Id as Cmd};
1535 use ui::shell::TOOL;1653 use ui::shell::TOOL;
...@@ -1548,11 +1666,14 @@ impl State {...@@ -1548,11 +1666,14 @@ impl State {
1548 .map_or_else(commands::Status::default, |(_, status)| *status)1666 .map_or_else(commands::Status::default, |(_, status)| *status)
1549 };1667 };
1550 let status_of = &status_of;1668 let status_of = &status_of;
1669 // A font or size applies where the font commands do.
1670 let fonts_apply = self.status(&Choice::Font(String::new()), &state).enabled;
1551 let fonts = &self.fonts;1671 let fonts = &self.fonts;
1552 let recent_fonts = &self.recent_fonts;1672 let recent_fonts = &self.recent_fonts;
1553 let pens = &self.toolbar;1673 let pens = &self.toolbar;
1554 let engine = &self.view.engine;1674 let engine = &self.view.engine;
1555 let session = self.session.as_ref();1675 let session = self.session.as_ref();
1676 let update = self.updates.status();
1556 let label = format!("{:.0}%", self.view.zoom() * 100.0);1677 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1557 let drawing_pens = self.pens();1678 let drawing_pens = self.pens();
1558 let ui = &mut self.ui;1679 let ui = &mut self.ui;
...@@ -1561,7 +1682,7 @@ impl State {...@@ -1561,7 +1682,7 @@ impl State {
1561 let mut choice = group(1682 let mut choice = group(
1562 ui,1683 ui,
1563 "navigate",1684 "navigate",
1564 4,1685 6,
1565 |ui| {1686 |ui| {
1566 let mut choice = None;1687 let mut choice = None;
1567 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {1688 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
...@@ -1577,7 +1698,7 @@ impl State {...@@ -1577,7 +1698,7 @@ impl State {
1577 choice = group(1698 choice = group(
1578 ui,1699 ui,
1579 "clipboard",1700 "clipboard",
1580 3,1701 5,
1581 |ui| {1702 |ui| {
1582 divider(ui, theme);1703 divider(ui, theme);
1583 let mut choice = None;1704 let mut choice = None;
...@@ -1587,9 +1708,10 @@ impl State {...@@ -1587,9 +1708,10 @@ impl State {
1587 } else if ui::shell::split_button(1708 } else if ui::shell::split_button(
1588 ui,1709 ui,
1589 "paste",1710 "paste",
1711 title(Cmd::Paste),
1590 art::PASTE,1712 art::PASTE,
1591 None,1713 None,
1592 false,1714 None,
1593 toolbar_popup("paste"),1715 toolbar_popup("paste"),
1594 )1716 )
1595 .clicked1717 .clicked
...@@ -1653,9 +1775,8 @@ impl State {...@@ -1653,9 +1775,8 @@ impl State {
1653 size: ui::Size::Pixels(120.0),1775 size: ui::Size::Pixels(120.0),
1654 strictness: 64.0 / 120.0,1776 strictness: 64.0 / 120.0,
1655 };1777 };
1656 if ui::shell::combo(ui, "font", &font, width).pressed {1778 let menu = toolbar_popup("font");
1657 ui.open_popup(toolbar_popup("font"));1779 ui::shell::combo(ui, "font", "Font", &font, width, menu, fonts_apply);
1658 }
1659 // Each family is named with the substitute it shows in, and picked by its own name; a1780 // Each family is named with the substitute it shows in, and picked by its own name; a
1660 // family of None heads a group. Typing searches the full list.1781 // family of None heads a group. Typing searches the full list.
1661 let named = |name: &str| {1782 let named = |name: &str| {
...@@ -1702,9 +1823,8 @@ impl State {...@@ -1702,9 +1823,8 @@ impl State {
1702 choice = Some(Choice::Font(name.to_owned()));1823 choice = Some(Choice::Font(name.to_owned()));
1703 }1824 }
1704 let combo = ui.id("size");1825 let combo = ui.id("size");
1705 if ui::shell::combo(ui, "size", &size, 44.0).pressed {1826 let menu = toolbar_popup("size");
1706 ui.open_popup(toolbar_popup("size"));1827 ui::shell::combo(ui, "size", "Font Size", &size, 44.0, menu, fonts_apply);
1707 }
1708 // A size typed in the field joins the list, in half points as stored.1828 // A size typed in the field joins the list, in half points as stored.
1709 let mut sizes = SIZES.to_vec();1829 let mut sizes = SIZES.to_vec();
1710 if let Some(typed) = ui::popup::query(ui, toolbar_popup("size"))1830 if let Some(typed) = ui::popup::query(ui, toolbar_popup("size"))
...@@ -1720,7 +1840,8 @@ impl State {...@@ -1720,7 +1840,8 @@ impl State {
1720 .iter()1840 .iter()
1721 .map(|label| ui::popup::Item {1841 .map(|label| ui::popup::Item {
1722 text: label,1842 text: label,
1723 checked: *label == size,1843 checked: Some(*label == size),
1844 current: *label == size,
1724 ..Default::default()1845 ..Default::default()
1725 })1846 })
1726 .collect();1847 .collect();
...@@ -1737,7 +1858,7 @@ impl State {...@@ -1737,7 +1858,7 @@ impl State {
1737 choice = group(1858 choice = group(
1738 ui,1859 ui,
1739 "character",1860 "character",
1740 6,1861 8,
1741 |ui| {1862 |ui| {
1742 divider(ui, theme);1863 divider(ui, theme);
1743 let mut choice = None;1864 let mut choice = None;
...@@ -1747,7 +1868,7 @@ impl State {...@@ -1747,7 +1868,7 @@ impl State {
1747 (1868 (
1748 "highlight",1869 "highlight",
1749 Cmd::Highlight,1870 Cmd::Highlight,
1750 &HIGHLIGHTS.map(|(color, _)| color)[..],1871 &HIGHLIGHTS[..],
1751 5,1872 5,
1752 "No Color",1873 "No Color",
1753 None,1874 None,
...@@ -1763,13 +1884,26 @@ impl State {...@@ -1763,13 +1884,26 @@ impl State {
1763 ] {1884 ] {
1764 let split = ui.id(part);1885 let split = ui.id(part);
1765 let icon = artwork(id).unwrap_or_default();1886 let icon = artwork(id).unwrap_or_default();
1766 if ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part))1887 if !status_of(id).enabled {
1767 .clicked1888 ui::shell::unavailable(ui, part, icon, text, true);
1889 } else if ui::shell::split_button(
1890 ui,
1891 part,
1892 title(id),
1893 icon,
1894 bar,
1895 None,
1896 toolbar_popup(part),
1897 )
1898 .clicked
1768 {1899 {
1769 choice = Some(Choice::Command(id));1900 choice = Some(Choice::Command(id));
1770 }1901 }
1771 tip(ui, id);1902 tip(ui, id);
1772 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();1903 let colors: Vec<_> = swatches
1904 .iter()
1905 .map(|&(color, name)| (colorref(color), name))
1906 .collect();
1773 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());1907 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
1774 if let Some(chosen) =1908 if let Some(chosen) =
1775 ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns)1909 ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns)
...@@ -1778,8 +1912,8 @@ impl State {...@@ -1778,8 +1912,8 @@ impl State {
1778 swatches1912 swatches
1779 .iter()1913 .iter()
1780 .zip(&colors)1914 .zip(&colors)
1781 .find(|(_, color)| **color == chosen)1915 .find(|(_, (color, _))| *color == chosen)
1782 .map(|(stored, _)| *stored)1916 .map(|((stored, _), _)| *stored)
1783 });1917 });
1784 choice = Some(if id == Cmd::Highlight {1918 choice = Some(if id == Cmd::Highlight {
1785 Choice::Highlight(color)1919 Choice::Highlight(color)
...@@ -1791,7 +1925,7 @@ impl State {...@@ -1791,7 +1925,7 @@ impl State {
1791 dropdown(1925 dropdown(
1792 ui,1926 ui,
1793 "script",1927 "script",
1794 Head::Menu(art::STRIKETHROUGH),1928 Head::Menu("Text Effects", art::STRIKETHROUGH),
1795 &[1929 &[
1796 Run(Cmd::Toggle(Toggle::Strikethrough)),1930 Run(Cmd::Toggle(Toggle::Strikethrough)),
1797 Run(Cmd::Toggle(Toggle::Subscript)),1931 Run(Cmd::Toggle(Toggle::Subscript)),
...@@ -1832,7 +1966,7 @@ impl State {...@@ -1832,7 +1966,7 @@ impl State {
1832 choice = group(1966 choice = group(
1833 ui,1967 ui,
1834 "paragraph",1968 "paragraph",
1835 5,1969 7,
1836 |ui| {1970 |ui| {
1837 divider(ui, theme);1971 divider(ui, theme);
1838 let mut choice = None;1972 let mut choice = None;
...@@ -1844,9 +1978,20 @@ impl State {...@@ -1844,9 +1978,20 @@ impl State {
1844 "numbering"1978 "numbering"
1845 };1979 };
1846 let icon = artwork(id).unwrap_or_default();1980 let icon = artwork(id).unwrap_or_default();
1847 let on = status_of(id).checked;1981 let status = status_of(id);
1848 if ui::shell::split_button(ui, part, icon, None, on, toolbar_popup(part))1982 if !status.enabled {
1849 .clicked1983 ui::shell::unavailable(ui, part, icon, text, true);
1984 toggled(ui, part, status);
1985 } else if ui::shell::split_button(
1986 ui,
1987 part,
1988 title(id),
1989 icon,
1990 None,
1991 status.checked,
1992 toolbar_popup(part),
1993 )
1994 .clicked
1850 {1995 {
1851 choice = Some(Choice::Command(id));1996 choice = Some(Choice::Command(id));
1852 }1997 }
...@@ -1931,10 +2076,17 @@ impl State {...@@ -1931,10 +2076,17 @@ impl State {
1931 }2076 }
1932 let aligned = alignments2077 let aligned = alignments
1933 .into_iter()2078 .into_iter()
1934 .find(|id| status_of(*id).checked)2079 .find(|id| status_of(*id).checked == Some(true))
1935 .unwrap_or(alignments[0]);2080 .unwrap_or(alignments[0]);
1936 let icon = artwork(aligned).unwrap_or_default();2081 let icon = artwork(aligned).unwrap_or_default();
1937 dropdown(ui, "align", Head::Menu(icon), &paragraph[1..], status_of).or(choice)2082 dropdown(
2083 ui,
2084 "align",
2085 Head::Menu("Alignment", icon),
2086 &paragraph[1..],
2087 status_of,
2088 )
2089 .or(choice)
1938 },2090 },
1939 |ui| {2091 |ui| {
1940 let mut entries = vec![2092 let mut entries = vec![
...@@ -1962,19 +2114,19 @@ impl State {...@@ -1962,19 +2114,19 @@ impl State {
1962 choice = group(2114 choice = group(
1963 ui,2115 ui,
1964 "tags",2116 "tags",
1965 7,2117 9,
1966 |ui| {2118 |ui| {
1967 divider(ui, theme);2119 divider(ui, theme);
1968 let mut choice = None;2120 let mut choice = None;
1969 for &(place, tag) in tags.iter().take(3) {2121 for &(place, tag) in tags.iter().take(3) {
1970 choice = tag_tool(ui, place, tag, status_of(Cmd::Tag(place))).or(choice);2122 choice = tag_tool(ui, place, tag, status_of(Cmd::Tag(place))).or(choice);
1971 }2123 }
1972 dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice)2124 dropdown(ui, "tags", Head::More("More Tags"), &all_tags, status_of).or(choice)
1973 },2125 },
1974 |ui| {2126 |ui| {
1975 let head = match tags.first() {2127 let head = match tags.first() {
1976 Some(&(place, tag)) => Head::Tag(place, tag),2128 Some(&(place, tag)) => Head::Tag(place, tag),
1977 None => Head::More,2129 None => Head::More("Tags"),
1978 };2130 };
1979 dropdown(ui, "menu", head, &all_tags, status_of)2131 dropdown(ui, "menu", head, &all_tags, status_of)
1980 },2132 },
...@@ -2008,17 +2160,25 @@ impl State {...@@ -2008,17 +2160,25 @@ impl State {
2008 Cmd::RecordAudio,2160 Cmd::RecordAudio,
2009 Cmd::RecordVideo,2161 Cmd::RecordVideo,
2010 ]2162 ]
2011 .map(Run);2163 .into_iter()
2164 .filter(|id| commands::offered(*id))
2165 .map(Run)
2166 .collect::<Vec<_>>();
2012 choice = group(2167 choice = group(
2013 ui,2168 ui,
2014 "insert",2169 "insert",
2015 2,2170 4,
2016 |ui| {2171 |ui| {
2017 divider(ui, theme);2172 divider(ui, theme);
2018 let mut choice = None;2173 let mut choice = None;
2019 if status_of(Cmd::Table).enabled {2174 if status_of(Cmd::Table).enabled {
2020 let anchor =2175 let anchor = ui::shell::menu_button(
2021 ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table"));2176 ui,
2177 "table",
2178 art::TABLE,
2179 None,
2180 toolbar_popup("table"),
2181 );
2022 tip(ui, Cmd::Table);2182 tip(ui, Cmd::Table);
2023 if let Some([columns, rows]) =2183 if let Some([columns, rows]) =
2024 ui::popup::table_picker(ui, toolbar_popup("table"), anchor, [10, 8])2184 ui::popup::table_picker(ui, toolbar_popup("table"), anchor, [10, 8])
...@@ -2027,32 +2187,68 @@ impl State {...@@ -2027,32 +2187,68 @@ impl State {
2027 }2187 }
2028 } else {2188 } else {
2029 ui::shell::unavailable(ui, "table", art::TABLE, text, true);2189 ui::shell::unavailable(ui, "table", art::TABLE, text, true);
2190 tip(ui, Cmd::Table);
2030 }2191 }
2031 for id in inserted {2192 for id in inserted {
2032 choice = tool(ui, id, status_of(id)).or(choice);2193 choice = tool(ui, id, status_of(id)).or(choice);
2033 }2194 }
2034 let mut entries = more.to_vec();2195 let mut entries = more.clone();
2035 entries.push(Rule);2196 entries.push(Rule);
2036 entries.extend(drawing.clone());2197 entries.extend(drawing.clone());
2037 dropdown(ui, "more", Head::More, &entries, status_of).or(choice)2198 dropdown(
2199 ui,
2200 "more",
2201 Head::More("More Insert Options"),
2202 &entries,
2203 status_of,
2204 )
2205 .or(choice)
2038 },2206 },
2039 |ui| {2207 |ui| {
2040 let mut entries = vec![Open("table", Cmd::Table)];2208 let mut entries = vec![Open("table", Cmd::Table)];
2041 entries.extend(inserted.map(Run));2209 entries.extend(inserted.map(Run));
2042 entries.push(Rule);2210 entries.push(Rule);
2043 entries.extend(more);2211 entries.extend(more.iter().copied());
2044 entries.push(Rule);2212 entries.push(Rule);
2045 entries.extend(drawing.clone());2213 entries.extend(drawing.clone());
2046 dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of)2214 dropdown(
2215 ui,
2216 "menu",
2217 Head::Menu("Insert", art::PLUS),
2218 &entries,
2219 status_of,
2220 )
2047 },2221 },
2048 )2222 )
2049 .or(choice);2223 .or(choice);
2224 // Where the row has room, some of Insert's menu shows as buttons too, and folds away
2225 // first, as the Draw group does.
2226 for (part, priority, ids) in [
2227 ("files", 0, [Cmd::ScreenClipping, Cmd::Attachment]),
2228 ("recording", 3, [Cmd::RecordAudio, Cmd::RecordVideo]),
2229 ] {
2230 choice = group(
2231 ui,
2232 part,
2233 priority,
2234 |ui| {
2235 divider(ui, theme);
2236 let mut choice = None;
2237 for id in ids.into_iter().filter(|id| commands::offered(*id)) {
2238 choice = tool(ui, id, status_of(id)).or(choice);
2239 }
2240 choice
2241 },
2242 |_| None,
2243 )
2244 .or(choice);
2245 }
2050 // Pen draws with the gallery's last pick. Folded, the group leaves its tools to2246 // Pen draws with the gallery's last pick. Folded, the group leaves its tools to
2051 // Insert's menus, so the narrowest row keeps its width.2247 // Insert's menus, so the narrowest row keeps its width.
2052 choice = group(2248 choice = group(
2053 ui,2249 ui,
2054 "draw",2250 "draw",
2055 1,2251 2,
2056 |ui| {2252 |ui| {
2057 divider(ui, theme);2253 divider(ui, theme);
2058 let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));2254 let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));
...@@ -2064,6 +2260,7 @@ impl State {...@@ -2064,6 +2260,7 @@ impl State {
2064 if ui::shell::split_button(2260 if ui::shell::split_button(
2065 ui,2261 ui,
2066 "pen",2262 "pen",
2263 title(Cmd::Pen),
2067 art::PEN,2264 art::PEN,
2068 bar,2265 bar,
2069 status.checked,2266 status.checked,
...@@ -2080,11 +2277,19 @@ impl State {...@@ -2080,11 +2277,19 @@ impl State {
2080 }2277 }
2081 } else {2278 } else {
2082 ui::shell::unavailable(ui, "pen", art::PEN, text, true);2279 ui::shell::unavailable(ui, "pen", art::PEN, text, true);
2280 tip(ui, Cmd::Pen);
2083 }2281 }
2084 for id in [Cmd::Eraser, Cmd::Lasso] {2282 for id in [Cmd::Eraser, Cmd::Lasso] {
2085 choice = tool(ui, id, status_of(id)).or(choice);2283 choice = tool(ui, id, status_of(id)).or(choice);
2086 }2284 }
2087 dropdown(ui, "shapes", Head::Menu(art::SHAPES), &shapes, status_of).or(choice)2285 dropdown(
2286 ui,
2287 "shapes",
2288 Head::Tools("Shapes", art::SHAPES),
2289 &shapes,
2290 status_of,
2291 )
2292 .or(choice)
2088 },2293 },
2089 |_| None,2294 |_| None,
2090 )2295 )
...@@ -2097,21 +2302,21 @@ impl State {...@@ -2097,21 +2302,21 @@ impl State {
2097 },2302 },
2098 );2303 );
2099 if let Some(session) = session {2304 if let Some(session) = session {
2100 sync::control(ui, session, theme);2305 sync::control(ui, session, &update, theme);
2101 }2306 }
2102 // Spelling awaits a platform spell checker.
2103 let views = [2307 let views = [
2104 Run(Cmd::Sidebar),2308 Run(Cmd::Sidebar),
2105 Run(Cmd::PageList),2309 Run(Cmd::PageList),
2106 Run(Cmd::DarkPages),2310 Run(Cmd::DarkPages),
2107 Run(Cmd::FullPageView),2311 Run(Cmd::FullPageView),
2108 Rule,2312 Rule,
2109 Soon("Spelling", Some(art::SPELLING)),2313 Run(Cmd::HideSpelling),
2314 Run(Cmd::Spelling),
2110 ];2315 ];
2111 choice = group(2316 choice = group(
2112 ui,2317 ui,
2113 "view",2318 "view",
2114 0,2319 1,
2115 |ui| {2320 |ui| {
2116 divider(ui, theme);2321 divider(ui, theme);
2117 let mut choice = None;2322 let mut choice = None;
...@@ -2120,6 +2325,7 @@ impl State {...@@ -2120,6 +2325,7 @@ impl State {
2120 ui,2325 ui,
2121 "page color",2326 "page color",
2122 art::PAGE_COLOR,2327 art::PAGE_COLOR,
2328 None,
2123 toolbar_popup("page color"),2329 toolbar_popup("page color"),
2124 );2330 );
2125 tip(ui, Cmd::PageColor);2331 tip(ui, Cmd::PageColor);
...@@ -2140,6 +2346,7 @@ impl State {...@@ -2140,6 +2346,7 @@ impl State {
2140 .or(choice.take());2346 .or(choice.take());
2141 } else {2347 } else {
2142 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);2348 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
2349 tip(ui, Cmd::PageColor);
2143 }2350 }
2144 choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice);2351 choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice);
2145 let level = ui.leaf(2352 let level = ui.leaf(
...@@ -2151,6 +2358,7 @@ impl State {...@@ -2151,6 +2358,7 @@ impl State {
2151 hover_fill: Some(theme.hover()),2358 hover_fill: Some(theme.hover()),
2152 radius: 4.0,2359 radius: 4.0,
2153 center: true,2360 center: true,
2361 role: Some(accesskit::Role::Button),
2154 ..Spec::default()2362 ..Spec::default()
2155 },2363 },
2156 );2364 );
...@@ -2158,8 +2366,18 @@ impl State {...@@ -2158,8 +2366,18 @@ impl State {
2158 choice = Some(Choice::Command(Cmd::ActualSize));2366 choice = Some(Choice::Command(Cmd::ActualSize));
2159 }2367 }
2160 tip(ui, Cmd::ActualSize);2368 tip(ui, Cmd::ActualSize);
2369 if let Some(node) = ui.access(ui.id("level")) {
2370 node.set_value(label.as_str());
2371 }
2161 choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice);2372 choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice);
2162 dropdown(ui, "view", Head::More, &views, status_of).or(choice)2373 dropdown(
2374 ui,
2375 "view",
2376 Head::More("More View Options"),
2377 &views,
2378 status_of,
2379 )
2380 .or(choice)
2163 },2381 },
2164 |ui| {2382 |ui| {
2165 let mut entries = vec![2383 let mut entries = vec![
...@@ -2171,7 +2389,13 @@ impl State {...@@ -2171,7 +2389,13 @@ impl State {
2171 Rule,2389 Rule,
2172 ];2390 ];
2173 entries.extend(views);2391 entries.extend(views);
2174 dropdown(ui, "menu", Head::Menu(art::ZOOM_IN), &entries, status_of)2392 dropdown(
2393 ui,
2394 "menu",
2395 Head::Menu("View", art::ZOOM_IN),
2396 &entries,
2397 status_of,
2398 )
2175 },2399 },
2176 )2400 )
2177 .or(choice);2401 .or(choice);
...@@ -2204,8 +2428,14 @@ impl State {...@@ -2204,8 +2428,14 @@ impl State {
2204 );2428 );
2205 if folded {2429 if folded {
2206 let open = self.ui.popup_open(search::results());2430 let open = self.ui.popup_open(search::results());
2207 if ui::shell::tool_button(&mut self.ui, "search", art::SEARCH, theme.text, open)2431 if ui::shell::tool_button(
2208 .pressed2432 &mut self.ui,
2433 "search",
2434 art::SEARCH,
2435 theme.text,
2436 Some(open),
2437 )
2438 .pressed
2209 {2439 {
2210 self.start_search();2440 self.start_search();
2211 }2441 }
...@@ -2213,7 +2443,7 @@ impl State {...@@ -2213,7 +2443,7 @@ impl State {
2213 } else if let Err(error) = self.search_box(theme) {2443 } else if let Err(error) = self.search_box(theme) {
2214 eprintln!("{error}");2444 eprintln!("{error}");
2215 }2445 }
2216 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {2446 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, None).clicked {
2217 self.commands.push(Command::NewPage { under: None });2447 self.commands.push(Command::NewPage { under: None });
2218 }2448 }
2219 tip(&mut self.ui, commands::Id::NewPage);2449 tip(&mut self.ui, commands::Id::NewPage);
...@@ -2222,7 +2452,7 @@ impl State {...@@ -2222,7 +2452,7 @@ impl State {
2222 } else {2452 } else {
2223 art::SIDEBAR_EXPAND2453 art::SIDEBAR_EXPAND
2224 };2454 };
2225 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {2455 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, None).clicked {
2226 self.pages_open = !self.pages_open;2456 self.pages_open = !self.pages_open;
2227 }2457 }
2228 tip(&mut self.ui, commands::Id::PageList);2458 tip(&mut self.ui, commands::Id::PageList);
...@@ -2244,9 +2474,14 @@ impl State {...@@ -2244,9 +2474,14 @@ impl State {
2244 flags: Flags::SCROLL | Flags::CLIP,2474 flags: Flags::SCROLL | Flags::CLIP,
2245 axis: Axis::Y,2475 axis: Axis::Y,
2246 size: [px(width), fill()],2476 size: [px(width), fill()],
2477 role: Some(accesskit::Role::TabList),
2247 ..Spec::default()2478 ..Spec::default()
2248 },2479 },
2249 );2480 );
2481 if let Some(node) = self.ui.access(panel) {
2482 node.set_label("Pages");
2483 node.set_orientation(accesskit::Orientation::Vertical);
2484 }
2250 let found = self2485 let found = self
2251 .search2486 .search
2252 .found_in(&session.library.key(&session.tabs[session.tab].path));2487 .found_in(&session.library.key(&session.tabs[session.tab].path));
...@@ -2298,6 +2533,10 @@ impl State {...@@ -2298,6 +2533,10 @@ impl State {
2298 for event in events {2533 for event in events {
2299 let response = match event {2534 let response = match event {
2300 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,2535 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,
2536 ui::Event::Pressure(pressure) => {
2537 self.view.set_pressure(pressure);
2538 continue;
2539 }
2301 ui::Event::PointerLeft => self.view.pointer_left(),2540 ui::Event::PointerLeft => self.view.pointer_left(),
2302 ui::Event::Button {2541 ui::Event::Button {
2303 button: MouseButton::Left,2542 button: MouseButton::Left,
...@@ -2327,7 +2566,9 @@ impl State {...@@ -2327,7 +2566,9 @@ impl State {
2327 self.open_text_menu()?;2566 self.open_text_menu()?;
2328 continue;2567 continue;
2329 }2568 }
2330 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,2569 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) | ui::Event::Access(_) => {
2570 continue;
2571 }
2331 // A conflict page takes no typing; commands and caret moves still apply.2572 // A conflict page takes no typing; commands and caret moves still apply.
2332 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,2573 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,
2333 ui::Event::Key { ref key, .. }2574 ui::Event::Key { ref key, .. }
...@@ -2445,7 +2686,11 @@ impl State {...@@ -2445,7 +2686,11 @@ impl State {
2445 self.open_path(&path);2686 self.open_path(&path);
2446 }2687 }
2447 }2688 }
2689 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
2448 Command::NewNotebook => self.new_notebook()?,2690 Command::NewNotebook => self.new_notebook()?,
2691 Command::UseICloud => self.use_icloud()?,
2692 #[cfg(target_os = "linux")]
2693 Command::Install => desktop::install(),
2449 Command::CloseNotebook(library) => self.close_notebook(&library),2694 Command::CloseNotebook(library) => self.close_notebook(&library),
2450 Command::Structure(library, change) => self.restructure(library, change),2695 Command::Structure(library, change) => self.restructure(library, change),
2451 // The index follows the section's page list.2696 // The index follows the section's page list.
...@@ -2475,6 +2720,7 @@ impl State {...@@ -2475,6 +2720,7 @@ impl State {
2475 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,2720 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
2476 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,2721 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
2477 Command::Choose(choice) => self.run(choice)?,2722 Command::Choose(choice) => self.run(choice)?,
2723 Command::TagPicture => self.pick_tag_picture(),
2478 }2724 }
2479 Ok(())2725 Ok(())
2480 }2726 }
...@@ -2541,7 +2787,7 @@ impl State {...@@ -2541,7 +2787,7 @@ impl State {
2541 return Ok(());2787 return Ok(());
2542 }2788 }
2543 // A recording goes on the page it started on.2789 // A recording goes on the page it started on.
2544 self.stop_recording()?;2790 self.stop_recording(true)?;
2545 // The page shown so far keeps what was typed while the next one loaded.2791 // The page shown so far keeps what was typed while the next one loaded.
2546 self.persist()?;2792 self.persist()?;
2547 if let Some(session) = &self.session {2793 if let Some(session) = &self.session {
...@@ -2692,7 +2938,7 @@ impl State {...@@ -2692,7 +2938,7 @@ impl State {
2692 self.ui.set_focus(Some(page()));2938 self.ui.set_focus(Some(page()));
2693 }2939 }
2694 self.title();2940 self.title();
2695 self.update_accessibility()?;2941 self.update_accessibility(true)?;
2696 self.window.request_redraw();2942 self.window.request_redraw();
2697 Ok(())2943 Ok(())
2698 }2944 }
...@@ -2719,7 +2965,7 @@ impl State {...@@ -2719,7 +2965,7 @@ impl State {
2719 LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale),2965 LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale),
2720 );2966 );
2721 let start = Instant::now();2967 let start = Instant::now();
2722 self.update_accessibility()?;2968 self.update_accessibility(true)?;
2723 lap("accessibility", start);2969 lap("accessibility", start);
2724 Ok(())2970 Ok(())
2725 }2971 }
...@@ -2841,6 +3087,24 @@ impl State {...@@ -2841,6 +3087,24 @@ impl State {
2841 Ok(())3087 Ok(())
2842 }3088 }
28433089
3090 /// Publishes the open section's stored edits now, rather than after a pause in typing,
3091 /// and waits up to `wait` for them to reach the file.
3092 fn publish_now(&self, wait: std::time::Duration) {
3093 let Some(session) = &self.session else {
3094 return;
3095 };
3096 let deadline = Instant::now() + wait;
3097 loop {
3098 session.section.wake();
3099 if Instant::now() >= deadline
3100 || session.section.pending().is_ok_and(|pending| pending.is_empty())
3101 {
3102 return;
3103 }
3104 std::thread::sleep(std::time::Duration::from_millis(50));
3105 }
3106 }
3107
2844 /// Hands the section the ops the editor recorded since the last call, as one edit; the3108 /// Hands the section the ops the editor recorded since the last call, as one edit; the
2845 /// section thread stores it.3109 /// section thread stores it.
2846 fn persist(&mut self) -> Result<(), Box<dyn Error>> {3110 fn persist(&mut self) -> Result<(), Box<dyn Error>> {
...@@ -3001,52 +3265,150 @@ impl State {...@@ -3001,52 +3265,150 @@ impl State {
3001 Ok(())3265 Ok(())
3002 }3266 }
30033267
3004 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {3268 /// Sends assistive technology what changed in the interface, then in the page where
3269 /// `page` asks or its tree is not grafted yet. The interface's tree goes first, since it
3270 /// holds the page's.
3271 fn update_accessibility(&mut self, page: bool) -> Result<(), Box<dyn Error>> {
3272 let built = self.ui.rect(self::page()).is_some();
3273 if !built && self.page_grafted {
3274 // Its graft is gone, and with it the tree, which must be sent whole again.
3275 self.accessibility.deactivate();
3276 self.page_grafted = false;
3277 }
3278 let graft = self::page().node();
3279 let title = self.window.title();
3280 let scale = self.window.scale_factor();
3281 let grafted = self.page_grafted;
3282 let mut focus_page = false;
3283 self.access_adapter.update_if_active(|| {
3284 let mut update = self.ui.accessibility(&title, scale);
3285 // The focus may rest on the graft only once it holds the page's tree.
3286 if update.focus == graft && !grafted {
3287 update.focus = ui::Id::ROOT.node();
3288 focus_page = true;
3289 }
3290 update
3291 });
3292 if !built || (!page && grafted) {
3293 return Ok(());
3294 }
3005 let mut error = None;3295 let mut error = None;
3006 let view = &self.view;3296 let mut sent = false;
3007 let corner = self.ui.rect(page()).unwrap_or_default();3297 let (view, ui, window) = (&self.view, &self.ui, &self.window);
3008 let scale = self.ui.scale();
3009 // Bounds are in the window's pixels, which `scale` renders fewer of when capped.
3010 let ratio = self.window.scale_factor() as f32 / scale;
3011 let viewport = canvas::gpu::Viewport {
3012 size: view.viewport.size.map(|side| (side as f32 * ratio) as u32),
3013 scale: view.viewport.scale * ratio,
3014 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),
3015 };
3016 self.access_adapter.update_if_active(|| {3298 self.access_adapter.update_if_active(|| {
3017 match self.accessibility.update(3299 sent = true;
3018 &view.editor,3300 page_tree(view, ui, window, &mut self.accessibility).unwrap_or_else(|failure| {
3019 view.scene.as_ref(),3301 error = Some(failure);
3020 viewport,3302 self.accessibility.deactivate();
3021 &self.window.title(),3303 let mut root = accesskit::Node::new(accesskit::Role::Group);
3022 view.outline_preview(),3304 root.set_label("Page");
3023 view.object_focus().and_then(|focus| focus.read_only()),3305 accesskit::TreeUpdate {
3024 ) {3306 nodes: vec![(accessibility::ROOT, root)],
3025 Ok(update) => update,3307 tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)),
3026 Err(failure) => {3308 tree_id: PAGE_TREE,
3027 error = Some(failure);3309 focus: accessibility::ROOT,
3028 self.accessibility.deactivate();
3029 let mut root = accesskit::Node::new(accesskit::Role::Window);
3030 root.set_label(self.window.title());
3031 accesskit::TreeUpdate {
3032 nodes: vec![(accessibility::ROOT, root)],
3033 tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)),
3034 tree_id: accesskit::TreeId::ROOT,
3035 focus: accessibility::ROOT,
3036 }
3037 }3310 }
3038 }3311 })
3039 });3312 });
3313 self.page_grafted = sent;
3314 if focus_page {
3315 self.access_adapter
3316 .update_if_active(|| accesskit::TreeUpdate {
3317 nodes: Vec::new(),
3318 tree: None,
3319 tree_id: accesskit::TreeId::ROOT,
3320 focus: graft,
3321 });
3322 }
3040 if let Some(error) = error {3323 if let Some(error) = error {
3041 return Err(error.into());3324 return Err(error.into());
3042 }3325 }
3043 Ok(())3326 Ok(())
3044 }3327 }
30453328
3329 /// Writes the window's whole accessibility tree to `path` as text, a line a node as the
3330 /// platform shows them, for a replay to check what assistive technology is told.
3331 fn write_accessibility(&self, path: &Path) -> Result<(), Box<dyn Error>> {
3332 use accesskit_consumer::{NodeRef, Tree, TreeChangeHandler, common_filter};
3333 struct Unwatched;
3334 impl TreeChangeHandler for Unwatched {
3335 fn node_added(&mut self, _: &NodeRef) {}
3336 fn node_updated(&mut self, _: &NodeRef, _: &NodeRef) {}
3337 fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {}
3338 fn node_removed(&mut self, _: &NodeRef) {}
3339 }
3340 fn write(node: &NodeRef, depth: usize, out: &mut String) {
3341 let data = node.data();
3342 *out += &format!("{}{:?}", " ".repeat(depth), node.role());
3343 for (name, text) in [
3344 ("", data.label()),
3345 ("= ", data.value()),
3346 ("keys ", data.keyboard_shortcut()),
3347 ("-- ", data.description()),
3348 ] {
3349 if let Some(text) = text {
3350 *out += &format!(" {name}{text:?}");
3351 }
3352 }
3353 for (state, on) in [
3354 ("toggled", data.toggled().is_some()),
3355 ("on", data.toggled() == Some(accesskit::Toggled::True)),
3356 ("expanded", data.is_expanded() == Some(true)),
3357 ("selected", data.is_selected() == Some(true)),
3358 ("disabled", data.is_disabled()),
3359 ("focused", node.is_focused()),
3360 ] {
3361 if on {
3362 *out += &format!(" [{state}]");
3363 }
3364 }
3365 out.push('\n');
3366 for child in node.filtered_children(common_filter) {
3367 write(&child, depth + 1, out);
3368 }
3369 }
3370 let graft = page().node();
3371 let mut chrome = self
3372 .ui
3373 .accessibility_tree(&self.window.title(), self.window.scale_factor());
3374 let focus = std::mem::replace(&mut chrome.focus, ui::Id::ROOT.node());
3375 let mut tree = Tree::new(chrome, true);
3376 if self.ui.rect(page()).is_some() {
3377 let mut fresh = accessibility::Accessibility::default();
3378 let page = page_tree(&self.view, &self.ui, &self.window, &mut fresh)?;
3379 tree.update_and_process_changes(page, &mut Unwatched);
3380 }
3381 if focus != graft || self.ui.rect(page()).is_some() {
3382 let update = accesskit::TreeUpdate {
3383 nodes: Vec::new(),
3384 tree: None,
3385 tree_id: accesskit::TreeId::ROOT,
3386 focus,
3387 };
3388 tree.update_and_process_changes(update, &mut Unwatched);
3389 }
3390 let mut out = String::new();
3391 write(&tree.state().root(), 0, &mut out);
3392 std::fs::write(path, out)?;
3393 Ok(())
3394 }
3395
3396 /// Forgets what assistive technology was sent, so the next update sends both trees whole.
3397 fn deactivate_accessibility(&mut self) {
3398 self.ui.deactivate_accessibility();
3399 self.accessibility.deactivate();
3400 self.page_grafted = false;
3401 }
3402
3046 fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> {3403 fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> {
3047 trace_input(&request);3404 trace_input(&request);
3048 use accesskit::{Action, ActionData};3405 use accesskit::{Action, ActionData};
3049 if request.target_tree != accesskit::TreeId::ROOT {3406 if request.target_tree == accesskit::TreeId::ROOT {
3407 self.ui.event(ui::Event::Access(request));
3408 self.window.request_redraw();
3409 return Ok(());
3410 }
3411 if request.target_tree != PAGE_TREE {
3050 return Ok(());3412 return Ok(());
3051 }3413 }
3052 if let Some(field) = self.accessibility.date_for_node(request.target_node) {3414 if let Some(field) = self.accessibility.date_for_node(request.target_node) {
...@@ -3143,6 +3505,12 @@ impl State {...@@ -3143,6 +3505,12 @@ impl State {
3143 self.window.scale_factor() as f32 * (most / longest).min(1.0)3505 self.window.scale_factor() as f32 * (most / longest).min(1.0)
3144 }3506 }
31453507
3508 /// Marks misspelled words on the page unless Hide Spelling Errors is on.
3509 fn show_spelling(&mut self) {
3510 self.view.spelling = self.spelling.clone().filter(|_| !self.hide_spelling);
3511 self.window.request_redraw();
3512 }
3513
3146 /// The paper the open page lies on: the theme's, in the page's colour.3514 /// The paper the open page lies on: the theme's, in the page's colour.
3147 fn paper(&self) -> canvas::gpu::Paper {3515 fn paper(&self) -> canvas::gpu::Paper {
3148 canvas::gpu::Paper {3516 canvas::gpu::Paper {
...@@ -3157,6 +3525,9 @@ impl State {...@@ -3157,6 +3525,9 @@ impl State {
3157 let start = Instant::now();3525 let start = Instant::now();
3158 let paper = self.paper();3526 let paper = self.paper();
3159 self.view.update_pictures(paper, &self.redraw);3527 self.view.update_pictures(paper, &self.redraw);
3528 if let Some(session) = &self.session {
3529 self.view.tag_art = session.library.tag_art();
3530 }
3160 let theme = &self.ui.theme;3531 let theme = &self.ui.theme;
3161 let page_primitives = self.view.primitives(TextColors {3532 let page_primitives = self.view.primitives(TextColors {
3162 caret: theme.caret,3533 caret: theme.caret,
...@@ -3281,7 +3652,7 @@ impl State {...@@ -3281,7 +3652,7 @@ impl State {
3281 pressed: true,3652 pressed: true,
3282 at,3653 at,
3283 } = event3654 } = event
3284 && matches!(self.ui.box_at(self.pointer), Some(id) if id == strip() || id == tab_row())3655 && matches!(self.ui.box_at(self.pointer), Some(id) if id == strip() || id == tab_row() || id == sidebar::header())
3285 {3656 {
3286 let double = self.strip_press.is_some_and(|last| {3657 let double = self.strip_press.is_some_and(|last| {
3287 at.saturating_duration_since(last) <= platform::double_click_interval()3658 at.saturating_duration_since(last) <= platform::double_click_interval()
...@@ -3328,6 +3699,37 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) ->...@@ -3328,6 +3699,37 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) ->
3328 }3699 }
3329}3700}
33303701
3702/// The page's accessibility tree, as changed since `access` last sent it, for the graft at
3703/// the page's box: bounds in the window's pixels, which the interface's scale renders fewer
3704/// of when capped.
3705fn page_tree(
3706 view: &PageView,
3707 ui: &Ui,
3708 window: &Window,
3709 access: &mut accessibility::Accessibility,
3710) -> Result<accesskit::TreeUpdate, onestore::page::text::EditError> {
3711 let corner = ui.rect(page()).unwrap_or_default();
3712 let scale = ui.scale();
3713 let ratio = window.scale_factor() as f32 / scale;
3714 let viewport = canvas::gpu::Viewport {
3715 size: view.viewport.size.map(|side| (side as f32 * ratio) as u32),
3716 scale: view.viewport.scale * ratio,
3717 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),
3718 };
3719 let update = access.update(
3720 &view.editor,
3721 view.scene.as_ref(),
3722 viewport,
3723 "Page",
3724 view.outline_preview(),
3725 view.object_focus().and_then(|focus| focus.read_only()),
3726 )?;
3727 Ok(accesskit::TreeUpdate {
3728 tree_id: PAGE_TREE,
3729 ..update
3730 })
3731}
3732
3331/// A section's colour as linear RGBA; sections without one take OneNote's default blue.3733/// A section's colour as linear RGBA; sections without one take OneNote's default blue.
3332fn section_color(color: Option<u32>) -> [f32; 4] {3734fn section_color(color: Option<u32>) -> [f32; 4] {
3333 color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref)3735 color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref)
...@@ -3346,6 +3748,7 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O...@@ -3346,6 +3748,7 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
3346 hover_fill: Some(draw::srgb(0xff, 0xe4, 0xa6)),3748 hover_fill: Some(draw::srgb(0xff, 0xe4, 0xa6)),
3347 pad: [12.0, 6.0],3749 pad: [12.0, 6.0],
3348 gap: 8.0,3750 gap: 8.0,
3751 role: Some(accesskit::Role::Button),
3349 ..Spec::default()3752 ..Spec::default()
3350 },3753 },
3351 );3754 );
...@@ -3770,6 +4173,7 @@ fn page_tab(...@@ -3770,6 +4173,7 @@ fn page_tab(
3770 pad: [pad, 0.0],4173 pad: [pad, 0.0],
3771 offset: [0.0, tab.shift],4174 offset: [0.0, tab.shift],
3772 shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]),4175 shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]),
4176 role: Some(accesskit::Role::Tab),
3773 ..Spec::default()4177 ..Spec::default()
3774 };4178 };
3775 let (spec, color) = if selected {4179 let (spec, color) = if selected {
...@@ -3805,6 +4209,10 @@ fn page_tab(...@@ -3805,6 +4209,10 @@ fn page_tab(
3805 (spec, color)4209 (spec, color)
3806 };4210 };
3807 let row = ui.open(id, spec);4211 let row = ui.open(id, spec);
4212 if let Some(node) = ui.access(row) {
4213 node.set_selected(selected);
4214 node.set_level(tab.indent as usize + 1);
4215 }
3808 let kept = match tab.renaming {4216 let kept = match tab.renaming {
3809 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),4217 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),
3810 None => {4218 None => {
...@@ -3924,17 +4332,23 @@ fn pen_gallery(...@@ -3924,17 +4332,23 @@ fn pen_gallery(
3924 } else {4332 } else {
3925 (pen.width / 25.0).clamp(1.5, 4.0)4333 (pen.width / 25.0).clamp(1.5, 4.0)
3926 };4334 };
3927 let across = (CELL[1] - tall) / 2.0;4335 // Centred between two spacers, which share what the stroke leaves of the cell.
4336 let space = Spec {
4337 size: [fill(), fill()],
4338 ..Spec::default()
4339 };
4340 ui.leaf("above", space.clone());
3928 ui.leaf(4341 ui.leaf(
3929 "stroke",4342 "stroke",
3930 Spec {4343 Spec {
3931 size: [fill(), fill()],4344 size: [fill(), px(tall)],
3932 fill: Some(pen.color.map_or(ink, colorref)),4345 fill: Some(pen.color.map_or(ink, colorref)),
3933 inset: [6.0, across, 6.0, across],4346 inset: [2.0, 0.0, 2.0, 0.0],
3934 radius: tall / 2.0,4347 radius: tall / 2.0,
3935 ..Spec::default()4348 ..Spec::default()
3936 },4349 },
3937 );4350 );
4351 ui.leaf("below", space);
3938 },4352 },
3939 )4353 )
3940}4354}
...@@ -3977,23 +4391,22 @@ enum Entry<'a> {...@@ -3977,23 +4391,22 @@ enum Entry<'a> {
3977 Run(commands::Id),4391 Run(commands::Id),
3978 /// Applies the tag at this place in the tag list.4392 /// Applies the tag at this place in the tag list.
3979 Tag(usize, &'a canvas::editor::NoteTag),4393 Tag(usize, &'a canvas::editor::NoteTag),
3980 /// Opens the toolbar's popup `name`, which applies the command, under the button the4394 /// Opens the toolbar's popup `name`, which applies the command, as a submenu.
3981 /// popup's own control would open it from, or where that is folded away, this menu's.
3982 Open(&'static str, commands::Id),4395 Open(&'static str, commands::Id),
3983 /// A command not built yet, shown disabled.
3984 Soon(&'static str, Option<&'static [&'static str]>),
3985 /// Rules off the entries after it.4396 /// Rules off the entries after it.
3986 Rule,4397 Rule,
3987}4398}
39884399
3989/// What opens a toolbar menu: the arrow of a button running a command, a button showing4400/// What opens a toolbar menu: the arrow of a button running a command, a button showing
3990/// artwork, or a bare arrow.4401/// artwork, or a bare arrow; the last two named for assistive technology.
3991enum Head<'a> {4402enum Head<'a> {
3992 Split(commands::Id),4403 Split(commands::Id),
3993 /// A button applying the tag at this place in the tag list.4404 /// A button applying the tag at this place in the tag list.
3994 Tag(usize, &'a canvas::editor::NoteTag),4405 Tag(usize, &'a canvas::editor::NoteTag),
3995 Menu(&'static [&'static str]),4406 Menu(&'static str, &'static [&'static str]),
3996 More,4407 /// A `Menu` of tools, lit while one of them is on.
4408 Tools(&'static str, &'static [&'static str]),
4409 More(&'static str),
3997}4410}
39984411
3999/// A toolbar menu `part` of `entries` opened by `head`, whose command enabled and checked as4412/// A toolbar menu `part` of `entries` opened by `head`, whose command enabled and checked as
...@@ -4007,13 +4420,19 @@ fn dropdown(...@@ -4007,13 +4420,19 @@ fn dropdown(
4007) -> Option<commands::Choice> {4420) -> Option<commands::Choice> {
4008 use commands::Choice;4421 use commands::Choice;
4009 let button = ui.id(part);4422 let button = ui.id(part);
4010 let menu = button.child("menu");4423 // Not the split button's arrow, `button.child("menu")`.
4424 let menu = button.child("popup");
4011 let mut choice = None;4425 let mut choice = None;
4426 let usable = entries.iter().any(|entry| match *entry {
4427 Entry::Run(id) | Entry::Open(_, id) => status_of(id).enabled,
4428 Entry::Tag(place, _) => status_of(commands::Id::Tag(place)).enabled,
4429 Entry::Rule => false,
4430 });
4012 let anchor = match head {4431 let anchor = match head {
4013 Head::Split(id) if status_of(id).enabled => {4432 Head::Split(id) if status_of(id).enabled => {
4014 let icon = artwork(id).unwrap_or_default();4433 let icon = artwork(id).unwrap_or_default();
4015 let on = status_of(id).checked;4434 let on = status_of(id).checked;
4016 if ui::shell::split_button(ui, part, icon, None, on, menu).clicked {4435 if ui::shell::split_button(ui, part, title(id), icon, None, on, menu).clicked {
4017 choice = Some(Choice::Command(id));4436 choice = Some(Choice::Command(id));
4018 }4437 }
4019 tip(ui, id);4438 tip(ui, id);
...@@ -4021,22 +4440,62 @@ fn dropdown(...@@ -4021,22 +4440,62 @@ fn dropdown(
4021 }4440 }
4022 Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => {4441 Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => {
4023 let on = status_of(commands::Id::Tag(place)).checked;4442 let on = status_of(commands::Id::Tag(place)).checked;
4024 if ui::shell::split_button(ui, part, tag_art(tag), None, on, menu).clicked {4443 let art = tag_art(tag);
4444 if ui::shell::split_button(ui, part, &tag.label, art, None, on, menu).clicked {
4025 choice = Some(Choice::Command(commands::Id::Tag(place)));4445 choice = Some(Choice::Command(commands::Id::Tag(place)));
4026 }4446 }
4027 tag_tip(ui, place, tag);4447 tag_tip(ui, place, tag);
4028 ui::Anchor::Below(ui.rect(button).unwrap_or_default())4448 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
4029 }4449 }
4030 // Where its command does not apply, the button only opens the menu.4450 // Where its command does not apply, the button only opens the menu.
4031 Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu),4451 Head::Split(id) => {
4032 Head::Tag(_, tag) => ui::shell::menu_button(ui, part, tag_art(tag), menu),4452 let icon = artwork(id).unwrap_or_default();
4033 Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu),4453 let anchor = ui::shell::menu_button(ui, part, icon, None, menu);
4034 Head::More => {4454 name(ui, button, title(id));
4455 anchor
4456 }
4457 Head::Tag(_, tag) => {
4458 let anchor = ui::shell::menu_button(ui, part, tag_art(tag), None, menu);
4459 name(ui, button, &tag.label);
4460 anchor
4461 }
4462 // A menu of nothing that applies fades as a button would.
4463 Head::Menu(label, _) | Head::Tools(label, _) | Head::More(label) if !usable => {
4464 let (icon, arrow) = match head {
4465 Head::Menu(_, icon) | Head::Tools(_, icon) => (icon, true),
4466 _ => (ui::shell::CHEVRON, false),
4467 };
4468 let tint = ui.theme.text;
4469 ui::shell::unavailable(ui, part, icon, tint, arrow);
4470 name(ui, button, label);
4471 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
4472 }
4473 Head::Menu(label, icon) => {
4474 let anchor = ui::shell::menu_button(ui, part, icon, None, menu);
4475 name(ui, button, label);
4476 anchor
4477 }
4478 Head::Tools(label, icon) => {
4479 let on = entries.iter().any(|entry| match *entry {
4480 Entry::Run(id) | Entry::Open(_, id) => status_of(id).checked == Some(true),
4481 _ => false,
4482 });
4483 let anchor = ui::shell::menu_button(ui, part, icon, Some(on), menu);
4484 name(ui, button, label);
4485 anchor
4486 }
4487 Head::More(label) => {
4035 let open = ui.popup_open(menu);4488 let open = ui.popup_open(menu);
4036 let text = ui.theme.text;4489 let text = ui.theme.text;
4037 if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, open).pressed {4490 if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, Some(open)).pressed {
4038 ui.open_popup(menu);4491 ui.open_popup(menu);
4039 }4492 }
4493 if let Some(node) = ui.access(button) {
4494 node.set_label(label);
4495 node.clear_toggled();
4496 node.set_has_popup(accesskit::HasPopup::Menu);
4497 node.set_expanded(open);
4498 }
4040 ui::Anchor::Below(ui.rect(button).unwrap_or_default())4499 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
4041 }4500 }
4042 };4501 };
...@@ -4048,9 +4507,24 @@ fn dropdown(...@@ -4048,9 +4507,24 @@ fn dropdown(
4048 _ => String::new(),4507 _ => String::new(),
4049 })4508 })
4050 .collect();4509 .collect();
4510 // A gallery row beside its command's own row, or under its button, is named for the
4511 // split button's arrow it stands in for.
4512 let runs = |id| {
4513 matches!(head, Head::Split(head) if head == id)
4514 || entries
4515 .iter()
4516 .any(|entry| matches!(entry, Entry::Run(run) if *run == id))
4517 };
4518 let options: Vec<_> = entries
4519 .iter()
4520 .map(|entry| match *entry {
4521 Entry::Open(_, id) if runs(id) => format!("{} Options", title(id)),
4522 _ => String::new(),
4523 })
4524 .collect();
4051 let (mut items, mut actions) = (Vec::new(), Vec::new());4525 let (mut items, mut actions) = (Vec::new(), Vec::new());
4052 let mut separated = false;4526 let mut separated = false;
4053 for (entry, key) in entries.iter().zip(&keys) {4527 for ((entry, key), options) in entries.iter().zip(&keys).zip(&options) {
4054 let item = match *entry {4528 let item = match *entry {
4055 Entry::Rule => {4529 Entry::Rule => {
4056 separated = true;4530 separated = true;
...@@ -4066,28 +4540,26 @@ fn dropdown(...@@ -4066,28 +4540,26 @@ fn dropdown(
4066 },4540 },
4067 Entry::Tag(place, tag) => ui::popup::Item {4541 Entry::Tag(place, tag) => ui::popup::Item {
4068 text: &tag.label,4542 text: &tag.label,
4069 icon: TagIcon::of(tag.shape, false).map(tag_sources),4543 icon: tags::artwork(tag),
4070 ink: tag.color.map(colorref),4544 ink: tag.color.map(colorref),
4071 highlight: tag.highlight.map(colorref),4545 highlight: tag.highlight.map(colorref),
4072 colored: true,4546 tint: Some([1.0; 4]),
4073 shortcut: key,4547 shortcut: key,
4074 checked: status_of(commands::Id::Tag(place)).checked,4548 checked: status_of(commands::Id::Tag(place)).checked,
4075 disabled: !status_of(commands::Id::Tag(place)).enabled,4549 disabled: !status_of(commands::Id::Tag(place)).enabled,
4076 ..Default::default()4550 ..Default::default()
4077 },4551 },
4078 Entry::Open(_, id) => ui::popup::Item {4552 Entry::Open(_, id) => ui::popup::Item {
4079 text: commands::command(id).title,4553 text: if options.is_empty() {
4554 commands::command(id).title
4555 } else {
4556 options
4557 },
4080 icon: artwork(id),4558 icon: artwork(id),
4081 shortcut: "›",4559 submenu: true,
4082 disabled: !status_of(id).enabled,4560 disabled: !status_of(id).enabled,
4083 ..Default::default()4561 ..Default::default()
4084 },4562 },
4085 Entry::Soon(text, icon) => ui::popup::Item {
4086 text,
4087 icon,
4088 disabled: true,
4089 ..Default::default()
4090 },
4091 };4563 };
4092 items.push(ui::popup::Item {4564 items.push(ui::popup::Item {
4093 separated: std::mem::take(&mut separated),4565 separated: std::mem::take(&mut separated),
...@@ -4095,17 +4567,25 @@ fn dropdown(...@@ -4095,17 +4567,25 @@ fn dropdown(
4095 });4567 });
4096 actions.push(*entry);4568 actions.push(*entry);
4097 }4569 }
4098 match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) {4570 let chosen = ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]);
4571 ui::popup::submenus(ui, menu, &items, |index| match actions[index] {
4572 Entry::Open(name, _) => Some(toolbar_popup(name)),
4573 _ => None,
4574 });
4575 match chosen {
4099 Some(Entry::Run(id)) => Some(Choice::Command(id)),4576 Some(Entry::Run(id)) => Some(Choice::Command(id)),
4100 Some(Entry::Tag(place, _)) => Some(Choice::Command(commands::Id::Tag(place))),4577 Some(Entry::Tag(place, _)) => Some(Choice::Command(commands::Id::Tag(place))),
4101 Some(Entry::Open(name, _)) => {
4102 ui.open_popup(toolbar_popup(name));
4103 None
4104 }
4105 _ => choice,4578 _ => choice,
4106 }4579 }
4107}4580}
41084581
4582/// Names box `id` to assistive technology, where no tooltip does.
4583fn name(ui: &mut Ui, id: Id, label: &str) {
4584 if let Some(node) = ui.access(id) {
4585 node.set_label(label);
4586 }
4587}
4588
4109/// A tool button for command `id`, lit while `status` checks it and faded while it does not4589/// A tool button for command `id`, lit while `status` checks it and faded while it does not
4110/// apply; returns the command when clicked.4590/// apply; returns the command when clicked.
4111fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<commands::Choice> {4591fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<commands::Choice> {
...@@ -4116,6 +4596,8 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma...@@ -4116,6 +4596,8 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma
4116 let tint = ui.theme.text;4596 let tint = ui.theme.text;
4117 if !status.enabled {4597 if !status.enabled {
4118 ui::shell::unavailable(ui, part, icon, tint, false);4598 ui::shell::unavailable(ui, part, icon, tint, false);
4599 toggled(ui, part, status);
4600 tip(ui, id);
4119 return None;4601 return None;
4120 }4602 }
4121 let clicked = ui::shell::tool_button(ui, part, icon, tint, status.checked).clicked;4603 let clicked = ui::shell::tool_button(ui, part, icon, tint, status.checked).clicked;
...@@ -4123,6 +4605,15 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma...@@ -4123,6 +4605,15 @@ fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<comma
4123 clicked.then_some(commands::Choice::Command(id))4605 clicked.then_some(commands::Choice::Command(id))
4124}4606}
41254607
4608/// Keeps a faded toggle `part` exposed as one, whether or not it is on.
4609fn toggled(ui: &mut Ui, part: impl std::hash::Hash, status: commands::Status) {
4610 if let Some(checked) = status.checked
4611 && let Some(node) = ui.access(ui.id(part))
4612 {
4613 node.set_toggled(checked.into());
4614 }
4615}
4616
4126/// A tool button applying the tag at `place` in the tag list, as `tool` builds a command's.4617/// A tool button applying the tag at `place` in the tag list, as `tool` builds a command's.
4127fn tag_tool(4618fn tag_tool(
4128 ui: &mut Ui,4619 ui: &mut Ui,
...@@ -4135,6 +4626,8 @@ fn tag_tool(...@@ -4135,6 +4626,8 @@ fn tag_tool(
4135 let tint = [1.0; 4];4626 let tint = [1.0; 4];
4136 if !status.enabled {4627 if !status.enabled {
4137 ui::shell::unavailable(ui, part, tag_art(tag), tint, false);4628 ui::shell::unavailable(ui, part, tag_art(tag), tint, false);
4629 toggled(ui, part, status);
4630 tag_tip(ui, place, tag);
4138 return None;4631 return None;
4139 }4632 }
4140 let clicked = ui::shell::tool_button(ui, part, tag_art(tag), tint, status.checked).clicked;4633 let clicked = ui::shell::tool_button(ui, part, tag_art(tag), tint, status.checked).clicked;
...@@ -4196,6 +4689,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {...@@ -4196,6 +4689,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
4196 Cmd::PageColor => art::PAGE_COLOR,4689 Cmd::PageColor => art::PAGE_COLOR,
4197 Cmd::ZoomIn => art::ZOOM_IN,4690 Cmd::ZoomIn => art::ZOOM_IN,
4198 Cmd::ZoomOut => art::ZOOM_OUT,4691 Cmd::ZoomOut => art::ZOOM_OUT,
4692 Cmd::HideSpelling | Cmd::Spelling => art::SPELLING,
4199 _ => return None,4693 _ => return None,
4200 })4694 })
4201}4695}
...@@ -4208,8 +4702,8 @@ fn tag_art(tag: &canvas::editor::NoteTag) -> &'static [&'static str] {...@@ -4208,8 +4702,8 @@ fn tag_art(tag: &canvas::editor::NoteTag) -> &'static [&'static str] {
4208 // Artwork is borrowed for the program's life, so each colour pair is made once.4702 // Artwork is borrowed for the program's life, so each colour pair is made once.
4209 type Chips = BTreeMap<(Option<u32>, Option<u32>), &'static [&'static str]>;4703 type Chips = BTreeMap<(Option<u32>, Option<u32>), &'static [&'static str]>;
4210 static CHIPS: Mutex<Chips> = Mutex::new(BTreeMap::new());4704 static CHIPS: Mutex<Chips> = Mutex::new(BTreeMap::new());
4211 if let Some(icon) = TagIcon::of(tag.shape, false) {4705 if let Some(art) = tags::artwork(tag) {
4212 return tag_sources(icon);4706 return art;
4213 }4707 }
4214 let hex = |colorref: u32| {4708 let hex = |colorref: u32| {
4215 let [red, green, blue, _] = colorref.to_le_bytes();4709 let [red, green, blue, _] = colorref.to_le_bytes();
...@@ -4243,11 +4737,14 @@ fn tag_tip(ui: &mut Ui, place: usize, tag: &canvas::editor::NoteTag) {...@@ -4243,11 +4737,14 @@ fn tag_tip(ui: &mut Ui, place: usize, tag: &canvas::editor::NoteTag) {
4243 ui::popup::tooltip(ui, &tag.label, &key, None);4737 ui::popup::tooltip(ui, &tag.label, &key, None);
4244}4738}
42454739
4246/// A tooltip naming command `id` and its chord on the box built last. It names the command,4740/// A tooltip naming command `id` and its chord on the box built last.
4247/// not a dialog it opens, so it leaves off the menu's ellipsis.
4248fn tip(ui: &mut Ui, id: commands::Id) {4741fn tip(ui: &mut Ui, id: commands::Id) {
4249 let title = commands::command(id).title.trim_end_matches('…');4742 ui::popup::tooltip(ui, title(id), &commands::shortcut(id), None);
4250 ui::popup::tooltip(ui, title, &commands::shortcut(id), None);4743}
4744
4745/// Command `id`'s title without the menu's ellipsis, naming the command, not a dialog it opens.
4746fn title(id: commands::Id) -> &'static str {
4747 commands::command(id).title.trim_end_matches('…')
4251}4748}
42524749
4253/// The line leading a toolbar group, as far from its buttons as the groups are apart.4750/// The line leading a toolbar group, as far from its buttons as the groups are apart.
...@@ -4369,11 +4866,18 @@ impl App {...@@ -4369,11 +4866,18 @@ impl App {
4369 "Keep Editing",4866 "Keep Editing",
4370 "Discard Changes",4867 "Discard Changes",
4371 ) {4868 ) {
4869 if let Some(state) = &self.state {
4870 state.publish_now(QUIT_PUBLISH);
4871 }
4372 event_loop.exit();4872 event_loop.exit();
4373 }4873 }
4374 }4874 }
4375}4875}
43764876
4877/// How long quitting waits for the open section's edits to reach its file; they stay in the
4878/// replica and publish at the next launch otherwise.
4879const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
4880
4377impl ApplicationHandler<UserEvent> for App {4881impl ApplicationHandler<UserEvent> for App {
4378 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {4882 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
4379 let event = match event {4883 let event = match event {
...@@ -4404,6 +4908,20 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4404,6 +4908,20 @@ impl ApplicationHandler<UserEvent> for App {
4404 self.close(event_loop);4908 self.close(event_loop);
4405 return;4909 return;
4406 }4910 }
4911 UserEvent::ICloudAccount => {
4912 if let Some(state) = &mut self.state {
4913 state.icloud_account_changed();
4914 state.window.request_redraw();
4915 }
4916 return;
4917 }
4918 UserEvent::Update => {
4919 if let Some(state) = &mut self.state {
4920 state.updated();
4921 state.window.request_redraw();
4922 }
4923 return;
4924 }
4407 UserEvent::Sync => {4925 UserEvent::Sync => {
4408 if let Some(state) = &mut self.state4926 if let Some(state) = &mut self.state
4409 && let Err(error) = state.synced()4927 && let Err(error) = state.synced()
...@@ -4436,11 +4954,21 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4436,11 +4954,21 @@ impl ApplicationHandler<UserEvent> for App {
4436 match replay {4954 match replay {
4437 Replay::Input(event) => state.input(event),4955 Replay::Input(event) => state.input(event),
4438 Replay::Snapshot(path) => state.snapshot = Some(path),4956 Replay::Snapshot(path) => state.snapshot = Some(path),
4957 Replay::Accessibility(path) => {
4958 if let Err(error) = state.write_accessibility(&path) {
4959 eprintln!("{error}");
4960 }
4961 }
4439 Replay::Tick => {}4962 Replay::Tick => {}
4440 Replay::Appearance(appearance) => {4963 Replay::Appearance(appearance) => {
4441 state.window.set_theme(Some(appearance));4964 state.window.set_theme(Some(appearance));
4442 state.set_appearance(appearance);4965 state.set_appearance(appearance);
4443 }4966 }
4967 Replay::Resize([width, height]) => {
4968 let _ = state
4969 .window
4970 .request_inner_size(LogicalSize::new(width, height));
4971 }
4444 Replay::Quit => {4972 Replay::Quit => {
4445 self.close(event_loop);4973 self.close(event_loop);
4446 return;4974 return;
...@@ -4464,12 +4992,12 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4464,12 +4992,12 @@ impl ApplicationHandler<UserEvent> for App {
4464 let was_marked = state.view.editor.marked_range().is_some();4992 let was_marked = state.view.editor.marked_range().is_some();
4465 let result = match event.window_event {4993 let result = match event.window_event {
4466 accesskit_winit::WindowEvent::InitialTreeRequested => {4994 accesskit_winit::WindowEvent::InitialTreeRequested => {
4467 state.accessibility.deactivate();4995 state.deactivate_accessibility();
4468 state.update_accessibility()4996 state.update_accessibility(true)
4469 }4997 }
4470 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),4998 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),
4471 accesskit_winit::WindowEvent::AccessibilityDeactivated => {4999 accesskit_winit::WindowEvent::AccessibilityDeactivated => {
4472 state.accessibility.deactivate();5000 state.deactivate_accessibility();
4473 Ok(())5001 Ok(())
4474 }5002 }
4475 };5003 };
...@@ -4525,7 +5053,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4525,7 +5053,7 @@ impl ApplicationHandler<UserEvent> for App {
4525 }5053 }
4526 if matches!(event, WindowEvent::CloseRequested) {5054 if matches!(event, WindowEvent::CloseRequested) {
4527 if let Some(state) = &mut self.state5055 if let Some(state) = &mut self.state
4528 && let Err(error) = state.stop_recording().and_then(|()| state.persist())5056 && let Err(error) = state.stop_recording(true).and_then(|()| state.persist())
4529 {5057 {
4530 eprintln!("{error}");5058 eprintln!("{error}");
4531 }5059 }
...@@ -4560,6 +5088,10 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4560,6 +5088,10 @@ impl ApplicationHandler<UserEvent> for App {
4560 }5088 }
4561 WindowEvent::Focused(focused) => {5089 WindowEvent::Focused(focused) => {
4562 state.ui.window_focused = focused;5090 state.ui.window_focused = focused;
5091 if !focused {
5092 // Someone switching to another device finds their edits there.
5093 state.publish_now(std::time::Duration::ZERO);
5094 }
4563 state.window.request_redraw();5095 state.window.request_redraw();
4564 }5096 }
4565 WindowEvent::Occluded(occluded) => {5097 WindowEvent::Occluded(occluded) => {
...@@ -4575,6 +5107,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4575,6 +5107,7 @@ impl ApplicationHandler<UserEvent> for App {
4575 }5107 }
4576 WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft),5108 WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft),
4577 WindowEvent::CursorMoved { position, .. } => {5109 WindowEvent::CursorMoved { position, .. } => {
5110 state.input(ui::Event::Pressure(platform::pen_pressure()));
4578 state.input(ui::Event::PointerMoved([5111 state.input(ui::Event::PointerMoved([
4579 position.x as f32 / scale,5112 position.x as f32 / scale,
4580 position.y as f32 / scale,5113 position.y as f32 / scale,
...@@ -4584,11 +5117,14 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4584,11 +5117,14 @@ impl ApplicationHandler<UserEvent> for App {
4584 state: pressed,5117 state: pressed,
4585 button,5118 button,
4586 ..5119 ..
4587 } => state.input(ui::Event::Button {5120 } => {
4588 button,5121 state.input(ui::Event::Pressure(platform::pen_pressure()));
4589 pressed: pressed == ElementState::Pressed,5122 state.input(ui::Event::Button {
4590 at: Instant::now(),5123 button,
4591 }),5124 pressed: pressed == ElementState::Pressed,
5125 at: Instant::now(),
5126 })
5127 }
4592 WindowEvent::MouseWheel { delta, phase, .. } => {5128 WindowEvent::MouseWheel { delta, phase, .. } => {
4593 if let MouseScrollDelta::PixelDelta(p) = delta {5129 if let MouseScrollDelta::PixelDelta(p) = delta {
4594 state.swipe_scroll(phase, [p.x as f32 / scale, p.y as f32 / scale]);5130 state.swipe_scroll(phase, [p.x as f32 / scale, p.y as f32 / scale]);
...@@ -4643,13 +5179,10 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4643,13 +5179,10 @@ impl ApplicationHandler<UserEvent> for App {
4643 if repaint || wake.is_some_and(|wake| wake <= now) {5179 if repaint || wake.is_some_and(|wake| wake <= now) {
4644 state.window.request_redraw();5180 state.window.request_redraw();
4645 }5181 }
4646 let next = [5182 let next = [blink, wake.filter(|wake| *wake > now)]
4647 blink,5183 .into_iter()
4648 wake.filter(|wake| *wake > now),5184 .flatten()
4649 ]5185 .min();
4650 .into_iter()
4651 .flatten()
4652 .min();
4653 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));5186 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));
4654 }5187 }
4655}5188}
...@@ -4667,7 +5200,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E...@@ -4667,7 +5200,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
4667/// Feeds a development script to the window from another thread, one command per line5200/// Feeds a development script to the window from another thread, one command per line
4668/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type5201/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type
4669/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,5202/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
4670/// `appearance light|dark` and `quit`.5203/// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
4671fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {5204fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
4672 let mut steps = Vec::new();5205 let mut steps = Vec::new();
4673 for line in script.lines().filter(|line| !line.trim().is_empty()) {5206 for line in script.lines().filter(|line| !line.trim().is_empty()) {
...@@ -4691,6 +5224,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -4691,6 +5224,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
4691 "wheel" => Ok(Replay::Input(ui::Event::Wheel(5224 "wheel" => Ok(Replay::Input(ui::Event::Wheel(
4692 numbers()?.try_into().map_err(|_| "wheel takes DX DY")?,5225 numbers()?.try_into().map_err(|_| "wheel takes DX DY")?,
4693 ))),5226 ))),
5227 "pressure" => Ok(Replay::Input(ui::Event::Pressure(match rest {
5228 "none" => None,
5229 level => Some(level.parse()?),
5230 }))),
4694 "press" => Ok(Replay::Input(button(true))),5231 "press" => Ok(Replay::Input(button(true))),
4695 "release" => Ok(Replay::Input(button(false))),5232 "release" => Ok(Replay::Input(button(false))),
4696 "key" => Ok(Replay::Input(ui::Event::Key {5233 "key" => Ok(Replay::Input(ui::Event::Key {
...@@ -4699,6 +5236,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -4699,6 +5236,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
4699 "Enter" => Key::Named(NamedKey::Enter),5236 "Enter" => Key::Named(NamedKey::Enter),
4700 "Backspace" => Key::Named(NamedKey::Backspace),5237 "Backspace" => Key::Named(NamedKey::Backspace),
4701 "Tab" => Key::Named(NamedKey::Tab),5238 "Tab" => Key::Named(NamedKey::Tab),
5239 "Space" => Key::Named(NamedKey::Space),
5240 "F5" => Key::Named(NamedKey::F5),
5241 "F6" => Key::Named(NamedKey::F6),
5242 "F7" => Key::Named(NamedKey::F7),
4702 "Left" => Key::Named(NamedKey::ArrowLeft),5243 "Left" => Key::Named(NamedKey::ArrowLeft),
4703 "Right" => Key::Named(NamedKey::ArrowRight),5244 "Right" => Key::Named(NamedKey::ArrowRight),
4704 "Up" => Key::Named(NamedKey::ArrowUp),5245 "Up" => Key::Named(NamedKey::ArrowUp),
...@@ -4722,7 +5263,13 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -4722,7 +5263,13 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
4722 ))),5263 ))),
4723 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),5264 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),
4724 "quit" => Ok(Replay::Quit),5265 "quit" => Ok(Replay::Quit),
5266 "resize" => Ok(Replay::Resize(
5267 numbers()?
5268 .try_into()
5269 .map_err(|_| "resize takes WIDTH HEIGHT")?,
5270 )),
4725 "snapshot" => Ok(Replay::Snapshot(rest.into())),5271 "snapshot" => Ok(Replay::Snapshot(rest.into())),
5272 "accessibility" => Ok(Replay::Accessibility(rest.into())),
4726 "appearance" => Ok(Replay::Appearance(match rest {5273 "appearance" => Ok(Replay::Appearance(match rest {
4727 "light" => winit::window::Theme::Light,5274 "light" => winit::window::Theme::Light,
4728 "dark" => winit::window::Theme::Dark,5275 "dark" => winit::window::Theme::Dark,
...@@ -4760,6 +5307,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -4760,6 +5307,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
4760}5307}
47615308
4762fn main() -> Result<(), Box<dyn Error>> {5309fn main() -> Result<(), Box<dyn Error>> {
5310 platform::with_pool(launch)
5311}
5312
5313fn launch() -> Result<(), Box<dyn Error>> {
5314 let mut args = std::env::args_os().skip(1);
5315 if args.next().is_some_and(|arg| arg == update::FINISH) {
5316 return update::finish(args);
5317 }
4763 let mut args = std::env::args_os().skip(1);5318 let mut args = std::env::args_os().skip(1);
4764 let mut positional = Vec::new();5319 let mut positional = Vec::new();
4765 let mut substitutes = Vec::new();5320 let mut substitutes = Vec::new();
...@@ -4897,6 +5452,9 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -4897,6 +5452,9 @@ fn main() -> Result<(), Box<dyn Error>> {
4897 reference,5452 reference,
4898 }5453 }
4899 };5454 };
5455 if screenshot.is_some() {
5456 screenshot::prepare();
5457 }
4900 let event_loop = platform::event_loop(screenshot.is_some())?;5458 let event_loop = platform::event_loop(screenshot.is_some())?;
4901 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {5459 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
4902 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;5460 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
...@@ -4916,6 +5474,13 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -4916,6 +5474,13 @@ fn main() -> Result<(), Box<dyn Error>> {
4916 startup_error: None,5474 startup_error: None,
4917 };5475 };
4918 event_loop.run_app(&mut app)?;5476 event_loop.run_app(&mut app)?;
5477 let restart = app
5478 .state
5479 .as_ref()
5480 .and_then(|state| state.updates.restarting());
5481 if let Some(staged) = restart {
5482 update::relaunch(&staged)?;
5483 }
4919 app.startup_error.map_or(Ok(()), Err)5484 app.startup_error.map_or(Ok(()), Err)
4920}5485}
49215486
crates/snowbound/src/manage.rs+69-9
...@@ -39,10 +39,6 @@ pub enum Structure {...@@ -39,10 +39,6 @@ pub enum Structure {
39 },39 },
40}40}
4141
42/// The colour OneNote 2010 gave each new notebook beside its default one, COLORREF
43/// (`corpus/notebook-management/native/new-notebook`).
44const NOTEBOOK_COLOR: u32 = 0x00aeba91;
45
46/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,42/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
47/// or one inside a moved group.43/// or one inside a moved group.
48fn follow(current: &str, from: &str, to: &str) -> String {44fn follow(current: &str, from: &str, to: &str) -> String {
...@@ -168,16 +164,44 @@ impl State {...@@ -168,16 +164,44 @@ impl State {
168 self.refresh()164 self.refresh()
169 }165 }
170166
171 /// Asks where to create a notebook and creates it, as File, New does in OneNote.167 /// Asks where to create a notebook and creates it, as File, New does in OneNote; the
168 /// panel starts in iCloud Drive, as Notes keeps notes there.
172 pub(crate) fn new_notebook(&mut self) -> Result<(), Box<dyn Error>> {169 pub(crate) fn new_notebook(&mut self) -> Result<(), Box<dyn Error>> {
173 let Some(root) = platform::pick_new("New Notebook", "My Notebook", "Create") else {170 let icloud = crate::icloud::folder().or_else(crate::icloud::drive);
171 let Some(root) =
172 platform::pick_new("New Notebook", "My Notebook", "Create", icloud.as_deref())
173 else {
174 return Ok(());174 return Ok(());
175 };175 };
176 self.create_notebook(root)
177 }
178
179 /// Opens the notebooks in the app's own iCloud Drive folder, or makes one there.
180 pub(crate) fn use_icloud(&mut self) -> Result<(), Box<dyn Error>> {
181 let folder = crate::icloud::folder().ok_or("iCloud Drive is off")?;
182 let mut notebooks: Vec<std::path::PathBuf> = std::fs::read_dir(&folder)?
183 .flatten()
184 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
185 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
186 .map(|entry| entry.path())
187 .collect();
188 notebooks.sort();
189 if notebooks.is_empty() {
190 return self.create_notebook(folder.join("My Notebook"));
191 }
192 for notebook in notebooks {
193 self.open_notebook(notebook.to_string_lossy().into_owned(), None);
194 }
195 Ok(())
196 }
197
198 /// Creates a notebook in the new folder `root` and opens it.
199 fn create_notebook(&mut self, root: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
176 let page = self.dated_page(None)?;200 let page = self.dated_page(None)?;
177 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));201 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
178 self.load(move || {202 self.load(move || {
179 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();203 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
180 let notebook = Notebook::create(&location, &cache, NOTEBOOK_COLOR, &page)?;204 let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?;
181 let library = Arc::new(Library::created(&location, notebook, &cache));205 let library = Arc::new(Library::created(&location, notebook, &cache));
182 let path = library206 let path = library
183 .first_section()207 .first_section()
...@@ -189,6 +213,42 @@ impl State {...@@ -189,6 +213,42 @@ impl State {
189 Ok(())213 Ok(())
190 }214 }
191215
216 /// Closes the notebooks in iCloud Drive once its account signs out or changes: their
217 /// folders go with the account. Replicas holding edits stay, the open section's also
218 /// exported as a recovery archive; signing in again and reopening publishes them.
219 pub(crate) fn icloud_account_changed(&mut self) {
220 let closing: Vec<Arc<Library>> = self
221 .notebooks
222 .iter()
223 .filter(|library| library.in_icloud())
224 .cloned()
225 .collect();
226 if let Some(session) = &self.session
227 && session.library.in_icloud()
228 && session
229 .section
230 .pending()
231 .is_ok_and(|pending| !pending.is_empty())
232 && let Some(folder) = platform::settings_dir().map(|folder| folder.join("Recovery"))
233 {
234 let archive = folder.join(format!(
235 "{}-{}.sqlite",
236 session.library.name,
237 crate::filetime()
238 ));
239 let exported = std::fs::create_dir_all(&folder)
240 .map_err(notebook::Error::from)
241 .and_then(|()| session.section.export_recovery(&archive));
242 if let Err(error) = exported {
243 eprintln!("{}: {error}", archive.display());
244 }
245 }
246 for library in closing {
247 self.close_notebook(&library);
248 }
249 crate::icloud::look_up(notify(self.proxy.clone()));
250 }
251
192 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,252 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,
193 /// and the sidebar and the next launch leave it out.253 /// and the sidebar and the next launch leave it out.
194 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {254 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
...@@ -584,7 +644,7 @@ mod tests {...@@ -584,7 +644,7 @@ mod tests {
584 std::fs::create_dir_all(&temporary).unwrap();644 std::fs::create_dir_all(&temporary).unwrap();
585 let root = temporary.join("Managed");645 let root = temporary.join("Managed");
586 let cache = temporary.join("cache");646 let cache = temporary.join("cache");
587 let mut notebook = Notebook::create(&root, &cache, NOTEBOOK_COLOR, &dated()).unwrap();647 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap();
588 for (folder, name) in [("", "New Section 2"), ("", "Binned")] {648 for (folder, name) in [("", "New Section 2"), ("", "Binned")] {
589 notebook.create_section(folder, name, &dated()).unwrap();649 notebook.create_section(folder, name, &dated()).unwrap();
590 }650 }
...@@ -816,7 +876,7 @@ mod tests {...@@ -816,7 +876,7 @@ mod tests {
816 let root = temporary.join("Emptied");876 let root = temporary.join("Emptied");
817 let location = root.to_str().unwrap();877 let location = root.to_str().unwrap();
818 let cache = temporary.join("cache");878 let cache = temporary.join("cache");
819 let mut notebook = Notebook::create(location, &cache, NOTEBOOK_COLOR, &dated()).unwrap();879 let mut notebook = Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap();
820 notebook.create_group("", "Group").unwrap();880 notebook.create_group("", "Group").unwrap();
821 notebook.create_section("Group", "Inner", &dated()).unwrap();881 notebook.create_section("Group", "Inner", &dated()).unwrap();
822882
crates/snowbound/src/media_linux.rs+5-5
...@@ -351,14 +351,14 @@ impl Recorder {...@@ -351,14 +351,14 @@ impl Recorder {
351 self.0.set(if paused { PAUSED } else { PLAYING });351 self.0.set(if paused { PAUSED } else { PLAYING });
352 }352 }
353353
354 /// Ends the recording, its file complete: the end travels down the pipeline so the354 /// Ends the recording, its file complete, with nothing left to run: the end travels
355 /// file's header and index are written.355 /// down the pipeline so the file's header and index are written.
356 pub fn stop(self) -> Result<(), String> {356 pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> {
357 let pipeline = &self.0;357 let pipeline = &self.0;
358 pipeline.set(PLAYING);358 pipeline.set(PLAYING);
359 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };359 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };
360 match pipeline.wait(PATIENCE, EOS | ERROR) {360 match pipeline.wait(PATIENCE, EOS | ERROR) {
361 Some(EOS) => Ok(()),361 Some(EOS) => Ok(|| Ok(())),
362 _ => Err("The recording stopped early. Try recording again.".into()),362 _ => Err("The recording stopped early. Try recording again.".into()),
363 }363 }
364 }364 }
...@@ -397,7 +397,7 @@ mod tests {...@@ -397,7 +397,7 @@ mod tests {
397 recorder.pause(false);397 recorder.pause(false);
398 }398 }
399 std::thread::sleep(std::time::Duration::from_millis(seconds * 500));399 std::thread::sleep(std::time::Duration::from_millis(seconds * 500));
400 recorder.stop().unwrap();400 recorder.stop().unwrap()().unwrap();
401 }401 }
402402
403 /// The live test sources stand in for a microphone and camera.403 /// The live test sources stand in for a microphone and camera.
crates/snowbound/src/media_macos.rs+27-9
...@@ -155,7 +155,8 @@ impl Player {...@@ -155,7 +155,8 @@ impl Player {
155 pub fn open(path: &Path, audible: bool) -> Option<Self> {155 pub fn open(path: &Path, audible: bool) -> Option<Self> {
156 let url = url(path)?;156 let url = url(path)?;
157 unsafe {157 unsafe {
158 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];158 // Mac OS X 10.6 has no AVFoundation.
159 let player: Allocated<AnyObject> = msg_send_id![AnyClass::get("AVAudioPlayer")?, alloc];
159 let player: Option<Retained<AnyObject>> = msg_send_id![160 let player: Option<Retained<AnyObject>> = msg_send_id![
160 player,161 player,
161 initWithContentsOfURL: &*url,162 initWithContentsOfURL: &*url,
...@@ -218,6 +219,13 @@ pub enum Recorder {...@@ -218,6 +219,13 @@ pub enum Recorder {
218/// Set when the capture session has finished writing its movie.219/// Set when the capture session has finished writing its movie.
219static MOVIE_WRITTEN: AtomicBool = AtomicBool::new(false);220static MOVIE_WRITTEN: AtomicBool = AtomicBool::new(false);
220221
222/// Mac OS X 10.6 has no AVFoundation to record with.
223fn recordable() -> Result<(), String> {
224 AnyClass::get("AVCaptureDevice")
225 .map(|_| ())
226 .ok_or_else(|| "To record, use Mac OS X 10.7 or later.".into())
227}
228
221/// What to do when the system refused `device` ("microphone" or "camera").229/// What to do when the system refused `device` ("microphone" or "camera").
222fn refused(device: &str, pane: &str) -> String {230fn refused(device: &str, pane: &str) -> String {
223 format!("Allow Snowbound to use the {device} in System Settings, Privacy & Security, {pane}.")231 format!("Allow Snowbound to use the {device} in System Settings, Privacy & Security, {pane}.")
...@@ -234,6 +242,7 @@ impl Recorder {...@@ -234,6 +242,7 @@ impl Recorder {
234 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the242 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the
235 /// microphone the first time; refused, recording fails with what to do about it.243 /// microphone the first time; refused, recording fails with what to do about it.
236 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {244 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {
245 recordable()?;
237 unsafe {246 unsafe {
238 if denied(AVMediaTypeAudio) {247 if denied(AVMediaTypeAudio) {
239 return Err(refused("microphone", "Microphone"));248 return Err(refused("microphone", "Microphone"));
...@@ -258,6 +267,7 @@ impl Recorder {...@@ -258,6 +267,7 @@ impl Recorder {
258 /// Records the default camera, with the microphone where there is one, as a Motion JPEG267 /// Records the default camera, with the microphone where there is one, as a Motion JPEG
259 /// AVI file at `path` once stopped. The system asks for each device the first time.268 /// AVI file at `path` once stopped. The system asks for each device the first time.
260 pub fn video(path: &Path) -> Result<Self, String> {269 pub fn video(path: &Path) -> Result<Self, String> {
270 recordable()?;
261 unsafe {271 unsafe {
262 if denied(AVMediaTypeVideo) {272 if denied(AVMediaTypeVideo) {
263 return Err(refused("camera", "Camera"));273 return Err(refused("camera", "Camera"));
...@@ -346,12 +356,13 @@ impl Recorder {...@@ -346,12 +356,13 @@ impl Recorder {
346 }356 }
347 }357 }
348358
349 /// Ends the recording, its file complete.359 /// Ends the recording. Its file is complete once what this returns has run, on any
350 pub fn stop(self) -> Result<(), String> {360 /// thread: a video's conversion takes a while.
351 match self {361 pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> {
362 let movie = match self {
352 Self::Audio(recorder) => {363 Self::Audio(recorder) => {
353 let _: () = unsafe { msg_send![&recorder, stop] };364 let _: () = unsafe { msg_send![&recorder, stop] };
354 Ok(())365 None
355 }366 }
356 Self::Video {367 Self::Video {
357 session,368 session,
...@@ -373,11 +384,18 @@ impl Recorder {...@@ -373,11 +384,18 @@ impl Recorder {
373 }384 }
374 let _: () = msg_send![&session, stopRunning];385 let _: () = msg_send![&session, stopRunning];
375 }386 }
376 let avi = avi(&movie).ok_or("The recording stopped early. Try recording again.");387 Some((movie, path))
377 let _ = std::fs::remove_file(&movie);
378 std::fs::write(&path, avi?).map_err(|error| error.to_string())
379 }388 }
380 }389 };
390 Ok(move || {
391 let Some((movie, path)) = movie else {
392 return Ok(());
393 };
394 let avi = objc2::rc::autoreleasepool(|_| avi(&movie))
395 .ok_or("The recording stopped early. Try recording again.");
396 let _ = std::fs::remove_file(&movie);
397 std::fs::write(&path, avi?).map_err(|error| error.to_string())
398 })
381 }399 }
382}400}
383401
crates/snowbound/src/menubar.rs+6
...@@ -33,6 +33,7 @@ const MENUS: &[Item] = &[...@@ -33,6 +33,7 @@ const MENUS: &[Item] = &[
33 "Snowbound",33 "Snowbound",
34 &[34 &[
35 Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None),35 Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None),
36 C(Id::CheckForUpdates),
36 S,37 S,
37 C(Id::Settings),38 C(Id::Settings),
38 S,39 S,
...@@ -54,6 +55,7 @@ const MENUS: &[Item] = &[...@@ -54,6 +55,7 @@ const MENUS: &[Item] = &[
54 &[55 &[
55 C(Id::NewNotebook),56 C(Id::NewNotebook),
56 C(Id::OpenNotebook),57 C(Id::OpenNotebook),
58 C(Id::OpenFromServer),
57 C(Id::CloseNotebook),59 C(Id::CloseNotebook),
58 S,60 S,
59 C(Id::NewSection),61 C(Id::NewSection),
...@@ -94,6 +96,7 @@ const MENUS: &[Item] = &[...@@ -94,6 +96,7 @@ const MENUS: &[Item] = &[
94 Item::Menu(96 Item::Menu(
95 "View",97 "View",
96 &[98 &[
99 C(Id::GoTo),
97 C(Id::CommandPalette),100 C(Id::CommandPalette),
98 S,101 S,
99 C(Id::Back),102 C(Id::Back),
...@@ -109,6 +112,8 @@ const MENUS: &[Item] = &[...@@ -109,6 +112,8 @@ const MENUS: &[Item] = &[
109 S,112 S,
110 C(Id::PageColor),113 C(Id::PageColor),
111 C(Id::DarkPages),114 C(Id::DarkPages),
115 C(Id::HideSpelling),
116 C(Id::Spelling),
112 ],117 ],
113 ),118 ),
114 Item::Menu(119 Item::Menu(
...@@ -390,6 +395,7 @@ mod tests {...@@ -390,6 +395,7 @@ mod tests {
390 " " => "Space".to_owned(),395 " " => "Space".to_owned(),
391 "\u{f702}" => "←".to_owned(),396 "\u{f702}" => "←".to_owned(),
392 "\u{f703}" => "→".to_owned(),397 "\u{f703}" => "→".to_owned(),
398 "\u{f70a}" => "F7".to_owned(),
393 key => key.to_uppercase(),399 key => key.to_uppercase(),
394 };400 };
395 format!("{held}{key}")401 format!("{held}{key}")
crates/snowbound/src/menus.rs+1-1
...@@ -217,7 +217,7 @@ impl State {...@@ -217,7 +217,7 @@ impl State {
217 (Target::Page(space), "Copy Link to Page") => {217 (Target::Page(space), "Copy Link to Page") => {
218 let copied = self218 let copied = self
219 .page_link(space, None)219 .page_link(space, None)
220 .and_then(|link| Ok(self.clipboard.set_text(link)?));220 .and_then(|link| self.clipboard.set_text(link));
221 if let Err(error) = copied {221 if let Err(error) = copied {
222 eprintln!("Copying the link failed: {error}");222 eprintln!("Copying the link failed: {error}");
223 }223 }
crates/snowbound/src/options.rs+67-7
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists1//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists
2//! them, grouped under the same headings, each kept when OK is chosen.2//! them, grouped under the same headings, each kept when OK is chosen.
33
4use crate::{State, platform, settings::ColorScheme};4use crate::{State, platform, settings::ColorScheme, update};
5use accesskit::Role;
5use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px};6use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px};
6use winit::keyboard::NamedKey;7use winit::keyboard::NamedKey;
78
...@@ -38,6 +39,7 @@ pub struct Options {...@@ -38,6 +39,7 @@ pub struct Options {
38 user_name: String,39 user_name: String,
39 color_scheme: ColorScheme,40 color_scheme: ColorScheme,
40 light_pages: bool,41 light_pages: bool,
42 automatic_updates: bool,
41}43}
4244
43fn id() -> Id {45fn id() -> Id {
...@@ -59,6 +61,7 @@ impl State {...@@ -59,6 +61,7 @@ impl State {
59 user_name: self.author.clone(),61 user_name: self.author.clone(),
60 color_scheme: self.color_scheme,62 color_scheme: self.color_scheme,
61 light_pages: self.light_pages,63 light_pages: self.light_pages,
64 automatic_updates: self.updates.automatic(),
62 });65 });
63 self.ui.open_popup(id());66 self.ui.open_popup(id());
64 self.ui.set_focus(Some(user_name()));67 self.ui.set_focus(Some(user_name()));
...@@ -90,9 +93,13 @@ impl State {...@@ -90,9 +93,13 @@ impl State {
90 radius: 8.0,93 radius: 8.0,
91 pad: [6.0, 6.0],94 pad: [6.0, 6.0],
92 anchor: Some(Anchor::Dialog),95 anchor: Some(Anchor::Dialog),
96 role: Some(Role::Dialog),
93 ..Spec::default()97 ..Spec::default()
94 },98 },
95 );99 );
100 if let Some(node) = ui.access(id()) {
101 node.set_label("Options");
102 }
96 ui.open(103 ui.open(
97 "body",104 "body",
98 Spec {105 Spec {
...@@ -100,7 +107,7 @@ impl State {...@@ -100,7 +107,7 @@ impl State {
100 ..Spec::default()107 ..Spec::default()
101 },108 },
102 );109 );
103 ui.open(110 let pages = ui.open(
104 "pages",111 "pages",
105 Spec {112 Spec {
106 axis: Axis::Y,113 axis: Axis::Y,
...@@ -109,9 +116,13 @@ impl State {...@@ -109,9 +116,13 @@ impl State {
109 radius: 5.0,116 radius: 5.0,
110 pad: [6.0, 6.0],117 pad: [6.0, 6.0],
111 gap: 2.0,118 gap: 2.0,
119 role: Some(Role::TabList),
112 ..Spec::default()120 ..Spec::default()
113 },121 },
114 );122 );
123 if let Some(node) = ui.access(pages) {
124 node.set_orientation(accesskit::Orientation::Vertical);
125 }
115 for (page, name) in Page::ALL {126 for (page, name) in Page::ALL {
116 let shown = page == options.page;127 let shown = page == options.page;
117 let spec = Spec {128 let spec = Spec {
...@@ -123,9 +134,15 @@ impl State {...@@ -123,9 +134,15 @@ impl State {
123 hover_fill: Some(theme.hover()),134 hover_fill: Some(theme.hover()),
124 radius: 4.0,135 radius: 4.0,
125 pad: [8.0, 0.0],136 pad: [8.0, 0.0],
137 role: Some(Role::Tab),
126 ..Spec::default()138 ..Spec::default()
127 };139 };
128 if ui.leaf(name, spec).clicked {140 let tab = ui.open(name, spec);
141 if let Some(node) = ui.access(tab) {
142 node.set_selected(shown);
143 }
144 ui.close();
145 if ui.signal(tab).clicked {
129 options.page = page;146 options.page = page;
130 }147 }
131 }148 }
...@@ -149,12 +166,11 @@ impl State {...@@ -149,12 +166,11 @@ impl State {
149 .iter()166 .iter()
150 .find(|(scheme, _)| *scheme == options.color_scheme)167 .find(|(scheme, _)| *scheme == options.color_scheme)
151 .map_or("", |(_, name)| name);168 .map_or("", |(_, name)| name);
152 if ui::shell::combo(ui, "combo", current, 140.0).pressed {169 ui::shell::combo(ui, "combo", "Color scheme", current, 140.0, schemes(), true);
153 ui.open_popup(schemes());
154 }
155 let items = SCHEMES.map(|(scheme, name)| Item {170 let items = SCHEMES.map(|(scheme, name)| Item {
156 text: name,171 text: name,
157 checked: scheme == options.color_scheme,172 checked: Some(scheme == options.color_scheme),
173 current: scheme == options.color_scheme,
158 ..Item::default()174 ..Item::default()
159 });175 });
160 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());176 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
...@@ -178,7 +194,49 @@ impl State {...@@ -178,7 +194,49 @@ impl State {
178 ..Spec::default()194 ..Spec::default()
179 },195 },
180 );196 );
197 if let Some(node) = ui.access(user_name()) {
198 node.set_label("User name");
199 }
181 });200 });
201 heading(ui, &theme, "Updates");
202 ui.leaf(
203 "version",
204 Spec {
205 size: [fill(), px(row)],
206 text: Some(&update::describe_running()),
207 pad: [8.0, 0.0],
208 ..Spec::default()
209 },
210 );
211 if ui::check_box(
212 ui,
213 "automatic-updates",
214 "Check for updates automatically",
215 options.automatic_updates,
216 )
217 .clicked
218 {
219 options.automatic_updates = !options.automatic_updates;
220 }
221 #[cfg(target_os = "linux")]
222 if crate::desktop::uninstallable() {
223 field(ui, "Installed:", |ui| {
224 let binary = crate::desktop::binary().unwrap_or_default();
225 ui.leaf(
226 "path",
227 Spec {
228 flags: Flags::CLIP,
229 size: [fill(), px(row)],
230 text: Some(&binary.to_string_lossy()),
231 color: Some(theme.text_dim),
232 ..Spec::default()
233 },
234 );
235 if ui::button(ui, "uninstall", "Uninstall…").clicked {
236 crate::desktop::uninstall();
237 }
238 });
239 }
182 }240 }
183 Page::Display => {241 Page::Display => {
184 heading(ui, &theme, "Display");242 heading(ui, &theme, "Display");
...@@ -246,6 +304,7 @@ impl State {...@@ -246,6 +304,7 @@ impl State {
246 self.author = name.to_owned();304 self.author = name.to_owned();
247 self.color_scheme = options.color_scheme;305 self.color_scheme = options.color_scheme;
248 self.light_pages = options.light_pages;306 self.light_pages = options.light_pages;
307 self.updates.set_automatic(options.automatic_updates);
249 self.follow_color_scheme();308 self.follow_color_scheme();
250 self.save_settings();309 self.save_settings();
251 } else if !cancel {310 } else if !cancel {
...@@ -267,6 +326,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) {...@@ -267,6 +326,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
267 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),326 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),
268 radius: 4.0,327 radius: 4.0,
269 pad: [8.0, 0.0],328 pad: [8.0, 0.0],
329 role: Some(Role::Heading),
270 ..Spec::default()330 ..Spec::default()
271 },331 },
272 );332 );
crates/snowbound/src/palette.rs+72-31
...@@ -1,33 +1,65 @@...@@ -1,33 +1,65 @@
1//! The command palette: the command table's commands, then the open notebooks' sections and1//! The palette: the open notebooks, their sections and every page the search index holds to
2//! every page the search index holds to go to, narrowed as typed.2//! go to, or after a leading `>` the command table's commands, narrowed as typed.
33
4use crate::{4use crate::{
5 Command, Library, State,5 Command, Library, State, art,
6 commands::{self, COMMANDS, Choice},6 commands::{self, Choice},
7 library,7 library,
8};8};
9use onestore::ExGuid;9use onestore::ExGuid;
10use std::sync::Arc;10use std::sync::Arc;
11use ui::{Id, popup::Item};11use ui::{Id, popup::Item};
1212
13/// What the palette's query starts with to list commands.
14pub(crate) const COMMANDS: &str = ">";
15
13pub(crate) fn id() -> Id {16pub(crate) fn id() -> Id {
14 Id::ROOT.child("palette")17 Id::ROOT.child("palette")
15}18}
1619
17enum Target {20enum Target {
18 Command(commands::Id),21 Command(commands::Id),
22 Notebook(String),
19 Section(Arc<Library>, String),23 Section(Arc<Library>, String),
20 Page(String, ExGuid),24 Page(String, ExGuid),
21}25}
2226
23/// A row: its text, the dim text after it, and what it runs; a heading runs nothing.27/// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing.
24struct Row {28struct Row {
25 text: String,29 text: String,
26 after: String,30 after: String,
31 icon: Option<&'static [&'static str]>,
32 tint: Option<[f32; 4]>,
27 disabled: bool,33 disabled: bool,
28 target: Option<Target>,34 target: Option<Target>,
29}35}
3036
37impl Row {
38 fn heading(text: &str) -> Self {
39 Self {
40 text: text.to_owned(),
41 after: String::new(),
42 icon: None,
43 tint: None,
44 disabled: false,
45 target: None,
46 }
47 }
48
49 fn item(&self) -> Item<'_> {
50 Item {
51 text: &self.text,
52 shortcut: &self.after,
53 icon: self.icon,
54 tint: self.tint,
55 disabled: self.disabled,
56 heading: self.target.is_none(),
57 separated: self.target.is_none(),
58 ..Item::default()
59 }
60 }
61}
62
31impl State {63impl State {
32 /// Builds the palette while it is open, and runs what is chosen from it.64 /// Builds the palette while it is open, and runs what is chosen from it.
33 pub(crate) fn palette(&mut self) {65 pub(crate) fn palette(&mut self) {
...@@ -35,31 +67,44 @@ impl State {...@@ -35,31 +67,44 @@ impl State {
35 return;67 return;
36 }68 }
37 let format = self.format_state();69 let format = self.format_state();
38 let mut rows: Vec<Row> = COMMANDS70 let mut commands: Vec<Row> = commands::COMMANDS
39 .iter()71 .iter()
72 .filter(|command| commands::offered(command.id))
40 .map(|command| Row {73 .map(|command| Row {
41 text: command.title.to_owned(),74 text: command.title.to_owned(),
42 after: commands::shortcut(command.id),75 after: commands::shortcut(command.id),
76 icon: crate::artwork(command.id),
77 tint: None,
43 disabled: !self.status(&Choice::Command(command.id), &format).enabled,78 disabled: !self.status(&Choice::Command(command.id), &format).enabled,
44 target: Some(Target::Command(command.id)),79 target: Some(Target::Command(command.id)),
45 })80 })
46 .collect();81 .collect();
47 rows.extend(self.tags.iter().enumerate().map(|(place, tag)| {82 commands.extend(self.tags.iter().enumerate().map(|(place, tag)| {
48 let id = commands::Id::Tag(place);83 let id = commands::Id::Tag(place);
49 Row {84 Row {
50 text: tag.label.clone(),85 text: tag.label.clone(),
51 after: commands::shortcut(id),86 after: commands::shortcut(id),
87 icon: Some(crate::tag_art(tag)),
88 tint: Some([1.0; 4]),
52 disabled: !self.status(&Choice::Command(id), &format).enabled,89 disabled: !self.status(&Choice::Command(id), &format).enabled,
53 target: Some(Target::Command(id)),90 target: Some(Target::Command(id)),
54 }91 }
55 }));92 }));
56 let heading = |text: &str| Row {93 let mut places = vec![Row::heading("Notebooks")];
57 text: text.to_owned(),94 places.extend(
58 after: String::new(),95 (self.notebooks.iter())
59 disabled: false,96 .filter(|library| library.catalog().is_some())
60 target: None,97 .map(|library| Row {
61 };98 text: library.name.clone(),
62 rows.push(heading("Sections"));99 after: String::new(),
100 icon: Some(art::NOTEBOOK),
101 tint: None,
102 disabled: false,
103 target: Some(Target::Notebook(library.location.clone())),
104 }),
105 );
106 places.push(Row::heading("Sections"));
107 let theme = &self.ui.theme;
63 for library in &self.notebooks {108 for library in &self.notebooks {
64 // A section opened on its own has no catalog, and lists itself at "".109 // A section opened on its own has no catalog, and lists itself at "".
65 let mut folders: Vec<_> = library.catalog().into_iter().collect();110 let mut folders: Vec<_> = library.catalog().into_iter().collect();
...@@ -80,21 +125,23 @@ impl State {...@@ -80,21 +125,23 @@ impl State {
80 paths.collect()125 paths.collect()
81 };126 };
82 for path in paths {127 for path in paths {
83 rows.extend(library.tabs(path).into_iter().map(|tab| Row {128 places.extend(library.tabs(path).into_iter().map(|tab| Row {
84 text: tab.name,129 text: tab.name,
85 after: library.name.clone(),130 after: library.name.clone(),
131 icon: Some(art::SECTION),
132 tint: Some(theme.section(crate::section_color(tab.color)).accent),
86 disabled: false,133 disabled: false,
87 target: Some(Target::Section(Arc::clone(library), tab.path)),134 target: Some(Target::Section(Arc::clone(library), tab.path)),
88 }));135 }));
89 }136 }
90 }137 }
91 rows.push(heading("Pages"));138 places.push(Row::heading("Pages"));
92 let index = self139 let index = self
93 .search140 .search
94 .index141 .index
95 .lock()142 .lock()
96 .unwrap_or_else(|poison| poison.into_inner());143 .unwrap_or_else(|poison| poison.into_inner());
97 rows.extend(index.entries().iter().map(|entry| {144 places.extend(index.entries().iter().map(|entry| {
98 let (location, path) = entry.section.split_once('\n').unwrap_or_default();145 let (location, path) = entry.section.split_once('\n').unwrap_or_default();
99 let mut section = library::section_name(path, &None);146 let mut section = library::section_name(path, &None);
100 if section.is_empty() {147 if section.is_empty() {
...@@ -109,30 +156,24 @@ impl State {...@@ -109,30 +156,24 @@ impl State {
109 entry.title.clone()156 entry.title.clone()
110 },157 },
111 after: section,158 after: section,
159 icon: Some(art::PAGE),
160 tint: None,
112 disabled: false,161 disabled: false,
113 target: Some(Target::Page(entry.section.clone(), entry.space)),162 target: Some(Target::Page(entry.section.clone(), entry.space)),
114 }163 }
115 }));164 }));
116 drop(index);165 drop(index);
117 let items: Vec<Item> = rows166 let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>());
118 .iter()
119 .map(|row| Item {
120 text: &row.text,
121 shortcut: &row.after,
122 disabled: row.disabled,
123 heading: row.target.is_none(),
124 separated: row.target.is_none(),
125 ..Item::default()
126 })
127 .collect();
128 let chosen = ui::popup::palette(167 let chosen = ui::popup::palette(
129 &mut self.ui,168 &mut self.ui,
130 id(),169 id(),
131 &items,170 &[(COMMANDS, &items[0]), ("", &items[1])],
132 "Search commands, sections and pages",171 "Search pages, sections and notebooks (type > for commands)",
133 );172 );
134 match chosen.and_then(|index| rows.swap_remove(index).target) {173 let mut rows = [commands, places];
174 match chosen.and_then(|(mode, index)| rows[mode].swap_remove(index).target) {
135 Some(Target::Command(command)) => self.choose(Choice::Command(command)),175 Some(Target::Command(command)) => self.choose(Choice::Command(command)),
176 Some(Target::Notebook(location)) => self.open_notebook(location, None),
136 Some(Target::Section(library, path)) => {177 Some(Target::Section(library, path)) => {
137 self.commands.push(Command::OpenSection(library, path));178 self.commands.push(Command::OpenSection(library, path));
138 }179 }
crates/snowbound/src/pane.rs+204-16
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1//! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the1//! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the
2//! search's results sorted and grouped, and the Tags Summary, listing tagged paragraphs.2//! search's results sorted and grouped, the Tags Summary, listing tagged paragraphs, and
3//! Spelling (F7), walking the page's marked words.
34
4use crate::search::{Scope, marked};5use crate::search::{Scope, marked};
5use crate::{State, Theme, art, page};6use crate::{State, Theme, art, page, platform};
6use canvas::gpu::tag_sources;7use canvas::gpu::tag_sources;
7use canvas::outline::TagIcon;8use canvas::outline::TagIcon;
8use canvas::search::{Found, Tagged};9use canvas::search::{Found, Tagged};
...@@ -30,6 +31,10 @@ pub enum Pane {...@@ -30,6 +31,10 @@ pub enum Pane {
30 unchecked: bool,31 unchecked: bool,
31 scope: TagScope,32 scope: TagScope,
32 },33 },
34 /// The word is `State::correction`; `chosen` is the suggestion Change takes.
35 Spelling {
36 chosen: usize,
37 },
33}38}
3439
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -330,18 +335,25 @@ fn popup(name: &str) -> Id {...@@ -330,18 +335,25 @@ fn popup(name: &str) -> Id {
330 Id::ROOT.child(("pane popup", name))335 Id::ROOT.child(("pane popup", name))
331}336}
332337
333/// A combo box `width` wide showing `current` of `names`, returning the one chosen.338/// A combo box `label`led, `width` wide, showing `current` of `names`, returning the one
334fn choice(ui: &mut Ui, name: &str, current: usize, names: &[&str], width: f32) -> Option<usize> {339/// chosen.
340fn choice(
341 ui: &mut Ui,
342 name: &str,
343 label: &str,
344 current: usize,
345 names: &[&str],
346 width: f32,
347) -> Option<usize> {
335 let combo = ui.id(name);348 let combo = ui.id(name);
336 if ui::shell::combo(ui, name, names[current], width).pressed {349 ui::shell::combo(ui, name, label, names[current], width, popup(name), true);
337 ui.open_popup(popup(name));
338 }
339 let items: Vec<_> = names350 let items: Vec<_> = names
340 .iter()351 .iter()
341 .enumerate()352 .enumerate()
342 .map(|(index, text)| ui::popup::Item {353 .map(|(index, text)| ui::popup::Item {
343 text,354 text,
344 checked: index == current,355 checked: Some(index == current),
356 current: index == current,
345 ..Default::default()357 ..Default::default()
346 })358 })
347 .collect();359 .collect();
...@@ -414,11 +426,151 @@ impl State {...@@ -414,11 +426,151 @@ impl State {
414426
415 /// Closes the pane; the Search Results pane takes its search, and the marks, with it.427 /// Closes the pane; the Search Results pane takes its search, and the marks, with it.
416 fn close_pane(&mut self) {428 fn close_pane(&mut self) {
429 self.correction = None;
417 if matches!(self.search.pane.take(), Some(Pane::Search { .. })) {430 if matches!(self.search.pane.take(), Some(Pane::Search { .. })) {
418 self.end_search();431 self.end_search();
419 }432 }
420 }433 }
421434
435 /// Opens the Spelling pane on the next marked word, as F7 does.
436 pub(crate) fn open_spelling_pane(&mut self) {
437 if !matches!(self.search.pane, Some(Pane::Spelling { .. })) {
438 self.close_pane();
439 }
440 self.next_word();
441 }
442
443 /// Shows the next marked word in the Spelling pane; past the last, the check is complete.
444 fn next_word(&mut self) {
445 match self.view.next_correction() {
446 Ok(Some((response, correction))) => {
447 self.respond(response);
448 self.correction = Some(correction);
449 self.search.pane = Some(Pane::Spelling { chosen: 0 });
450 }
451 Ok(None) => {
452 self.close_pane();
453 platform::alert("The spelling check is complete.", "");
454 }
455 Err(error) => eprintln!("{error}"),
456 }
457 }
458
459 fn spelling_pane(&mut self, theme: &Theme, mut chosen: usize) {
460 let Some(correction) = &self.correction else {
461 return;
462 };
463 let ui = &mut self.ui;
464 label(
465 ui,
466 theme,
467 "kind",
468 if correction.repeated {
469 "Repeated word:"
470 } else {
471 "Current spelling:"
472 },
473 );
474 ui.leaf(
475 "word",
476 Spec {
477 size: [fill(), px(ui::shell::TOOL)],
478 text: Some(&correction.word),
479 bold: true,
480 fill: Some(theme.base),
481 border: Some(theme.chip),
482 radius: 4.0,
483 pad: [6.0, 0.0],
484 ..Spec::default()
485 },
486 );
487 ui.open(
488 "actions",
489 Spec {
490 size: [fill(), px(theme.font_size * 2.0)],
491 gap: 4.0,
492 ..Spec::default()
493 },
494 );
495 let [first, second] = if correction.repeated {
496 ["Delete", "Ignore"]
497 } else {
498 ["Ignore", "Add to Dictionary"]
499 };
500 let pressed = [first, second].map(|text| ui::button(ui, text, text).clicked);
501 ui.close();
502 let mut change = false;
503 if !correction.repeated {
504 label(ui, theme, "suggestions label", "Suggestions:");
505 ui.open(
506 "suggestions",
507 Spec {
508 axis: Axis::Y,
509 size: [fill(), px(TAGGED * 5.0 + 2.0)],
510 fill: Some(theme.base),
511 border: Some(theme.chip),
512 radius: 4.0,
513 pad: [1.0, 1.0],
514 role: Some(accesskit::Role::List),
515 ..Spec::default()
516 },
517 );
518 for (index, suggestion) in correction.suggestions.iter().enumerate() {
519 let row = ui.leaf(
520 index,
521 Spec {
522 flags: Flags::CLICKABLE,
523 size: [fill(), px(TAGGED)],
524 text: Some(suggestion),
525 fill: (index == chosen).then(|| theme.hover()),
526 hover_fill: Some(theme.hover()),
527 pad: [6.0, 0.0],
528 role: Some(accesskit::Role::ListItem),
529 ..Spec::default()
530 },
531 );
532 if row.clicked {
533 chosen = index;
534 change = row.unit != draw::edit::SelectionUnit::Grapheme;
535 }
536 }
537 if correction.suggestions.is_empty() {
538 label(ui, theme, "none", "(No Spelling Suggestions)");
539 }
540 ui.close();
541 if !correction.suggestions.is_empty() {
542 change |= ui::button(ui, "change", "Change").clicked;
543 }
544 }
545 self.search.pane = Some(Pane::Spelling { chosen });
546 let Some(correction) = self
547 .correction
548 .take_if(|_| change || pressed.contains(&true))
549 else {
550 return;
551 };
552 let spelling = self.view.spelling.clone();
553 let text = match (correction.repeated, pressed) {
554 (_, _) if change => correction.suggestions.get(chosen).map(String::as_str),
555 (true, [true, _]) => Some(""),
556 (false, [false, true]) => {
557 spelling.inspect(|spelling| spelling.learn(&correction.word));
558 None
559 }
560 _ => {
561 spelling.inspect(|spelling| spelling.ignore(&correction.word));
562 None
563 }
564 };
565 if let Some(text) = text {
566 match self.view.correct(&correction, text) {
567 Ok(response) => self.respond(response),
568 Err(error) => eprintln!("{error}"),
569 }
570 }
571 self.next_word();
572 }
573
422 /// The open pane at the window's right, easing open and closed.574 /// The open pane at the window's right, easing open and closed.
423 pub(crate) fn task_pane(&mut self, theme: &Theme) {575 pub(crate) fn task_pane(&mut self, theme: &Theme) {
424 let id = self.ui.id("task pane");576 let id = self.ui.id("task pane");
...@@ -465,16 +617,19 @@ impl State {...@@ -465,16 +617,19 @@ impl State {
465 text: Some(match pane {617 text: Some(match pane {
466 Pane::Search { .. } => "Search Results",618 Pane::Search { .. } => "Search Results",
467 Pane::Tags { .. } => "Tags Summary",619 Pane::Tags { .. } => "Tags Summary",
620 Pane::Spelling { .. } => "Spelling",
468 }),621 }),
469 bold: true,622 bold: true,
470 ..Spec::default()623 ..Spec::default()
471 },624 },
472 );625 );
473 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false)626 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None)
474 .clicked627 .clicked
475 {628 {
476 self.close_pane();629 self.close_pane();
477 }630 }
631 let close = self.ui.id("close");
632 crate::name(&mut self.ui, close, "Close");
478 self.ui.close();633 self.ui.close();
479 match self.search.pane {634 match self.search.pane {
480 Some(Pane::Search { sort, descending }) => {635 Some(Pane::Search { sort, descending }) => {
...@@ -487,6 +642,7 @@ impl State {...@@ -487,6 +642,7 @@ impl State {
487 unchecked,642 unchecked,
488 scope,643 scope,
489 }) => self.tags_pane(theme, group, unchecked, scope),644 }) => self.tags_pane(theme, group, unchecked, scope),
645 Some(Pane::Spelling { chosen }) => self.spelling_pane(theme, chosen),
490 None => {}646 None => {}
491 }647 }
492 }648 }
...@@ -532,9 +688,11 @@ impl State {...@@ -532,9 +688,11 @@ impl State {
532 border: Some(border),688 border: Some(border),
533 radius: 4.0,689 radius: 4.0,
534 pad: [6.0, 0.0],690 pad: [6.0, 0.0],
691 role: Some(accesskit::Role::SearchInput),
535 ..Spec::default()692 ..Spec::default()
536 },693 },
537 );694 );
695 crate::name(&mut self.ui, field(), "Search");
538 if std::mem::take(&mut self.search.claim) {696 if std::mem::take(&mut self.search.claim) {
539 self.ui.set_focus(Some(field()));697 self.ui.set_focus(Some(field()));
540 }698 }
...@@ -544,7 +702,14 @@ impl State {...@@ -544,7 +702,14 @@ impl State {
544 .iter()702 .iter()
545 .position(|scope| *scope == self.search.scope)703 .position(|scope| *scope == self.search.scope)
546 .unwrap_or_default();704 .unwrap_or_default();
547 if let Some(index) = choice(&mut self.ui, "scope", current, &names, INSIDE) {705 if let Some(index) = choice(
706 &mut self.ui,
707 "scope",
708 "Search scope",
709 current,
710 &names,
711 INSIDE,
712 ) {
548 self.search.scope = Scope::ALL[index];713 self.search.scope = Scope::ALL[index];
549 }714 }
550 self.ui.open(715 self.ui.open(
...@@ -561,7 +726,7 @@ impl State {...@@ -561,7 +726,7 @@ impl State {
561 .position(|listed| *listed == sort)726 .position(|listed| *listed == sort)
562 .unwrap_or_default();727 .unwrap_or_default();
563 let width = INSIDE - ui::shell::TOOL - 2.0;728 let width = INSIDE - ui::shell::TOOL - 2.0;
564 if let Some(index) = choice(&mut self.ui, "by", current, &names, width) {729 if let Some(index) = choice(&mut self.ui, "by", "Sort by", current, &names, width) {
565 sort = Sort::ALL[index];730 sort = Sort::ALL[index];
566 // Dates start newest first, names from the start of the alphabet.731 // Dates start newest first, names from the start of the alphabet.
567 descending = sort == Sort::Date;732 descending = sort == Sort::Date;
...@@ -571,7 +736,7 @@ impl State {...@@ -571,7 +736,7 @@ impl State {
571 } else {736 } else {
572 art::CHEVRON_UP737 art::CHEVRON_UP
573 };738 };
574 if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, false).clicked {739 if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, None).clicked {
575 descending = !descending;740 descending = !descending;
576 }741 }
577 let tip = if descending {742 let tip = if descending {
...@@ -603,14 +768,16 @@ impl State {...@@ -603,14 +768,16 @@ impl State {
603 })768 })
604 .collect();769 .collect();
605 let before = self.search.selected;770 let before = self.search.selected;
771 let list = Id::ROOT.child("pane results");
606 let clicked = ui::list(772 let clicked = ui::list(
607 &mut self.ui,773 &mut self.ui,
608 Id::ROOT.child("pane results"),774 list,
609 Spec {775 Spec {
610 size: [fill(), fill()],776 size: [fill(), fill()],
611 fill: Some(theme.base),777 fill: Some(theme.base),
612 border: Some(theme.chip),778 border: Some(theme.chip),
613 radius: 4.0,779 radius: 4.0,
780 role: Some(accesskit::Role::ListBox),
614 ..Spec::default()781 ..Spec::default()
615 },782 },
616 ui::List {783 ui::List {
...@@ -621,6 +788,10 @@ impl State {...@@ -621,6 +788,10 @@ impl State {
621 },788 },
622 &mut self.search.selected,789 &mut self.search.selected,
623 |ui, row| {790 |ui, row| {
791 if let Some(node) = ui.access(list.child(row.key)) {
792 node.set_role(accesskit::Role::ListBoxOption);
793 node.set_selected(row.selected);
794 }
624 if let Ok(group) = rows.starts.binary_search(&row.index) {795 if let Ok(group) = rows.starts.binary_search(&row.index) {
625 heading(ui, theme, &headings[group]);796 heading(ui, theme, &headings[group]);
626 }797 }
...@@ -633,6 +804,11 @@ impl State {...@@ -633,6 +804,11 @@ impl State {
633 );804 );
634 },805 },
635 );806 );
807 if let Some(key) = self.search.selected
808 && let Some(node) = self.ui.access(field())
809 {
810 node.set_active_descendant(list.child(key).node());
811 }
636 let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter);812 let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter);
637 let chosen = clicked.or_else(|| {813 let chosen = clicked.or_else(|| {
638 self.search814 self.search
...@@ -670,7 +846,14 @@ impl State {...@@ -670,7 +846,14 @@ impl State {
670 .iter()846 .iter()
671 .position(|listed| *listed == group)847 .position(|listed| *listed == group)
672 .unwrap_or_default();848 .unwrap_or_default();
673 if let Some(index) = choice(&mut self.ui, "group", current, &names, INSIDE) {849 if let Some(index) = choice(
850 &mut self.ui,
851 "group",
852 "Group tags by",
853 current,
854 &names,
855 INSIDE,
856 ) {
674 group = Group::ALL[index];857 group = Group::ALL[index];
675 }858 }
676 if ui::check_box(859 if ui::check_box(
...@@ -686,14 +869,16 @@ impl State {...@@ -686,14 +869,16 @@ impl State {
686 let tagged = self.tagged(scope, unchecked);869 let tagged = self.tagged(scope, unchecked);
687 let (order, rows, headings) = arrange_tags(&tagged, group);870 let (order, rows, headings) = arrange_tags(&tagged, group);
688 let mut selected = None;871 let mut selected = None;
872 let list = Id::ROOT.child("pane tags");
689 let clicked = ui::list(873 let clicked = ui::list(
690 &mut self.ui,874 &mut self.ui,
691 Id::ROOT.child("pane tags"),875 list,
692 Spec {876 Spec {
693 size: [fill(), fill()],877 size: [fill(), fill()],
694 fill: Some(theme.base),878 fill: Some(theme.base),
695 border: Some(theme.chip),879 border: Some(theme.chip),
696 radius: 4.0,880 radius: 4.0,
881 role: Some(accesskit::Role::List),
697 ..Spec::default()882 ..Spec::default()
698 },883 },
699 ui::List {884 ui::List {
...@@ -704,6 +889,9 @@ impl State {...@@ -704,6 +889,9 @@ impl State {
704 },889 },
705 &mut selected,890 &mut selected,
706 |ui, row| {891 |ui, row| {
892 if let Some(node) = ui.access(list.child(row.key)) {
893 node.set_role(accesskit::Role::ListItem);
894 }
707 if let Ok(at) = rows.starts.binary_search(&row.index) {895 if let Ok(at) = rows.starts.binary_search(&row.index) {
708 heading(ui, theme, &headings[at]);896 heading(ui, theme, &headings[at]);
709 }897 }
...@@ -716,7 +904,7 @@ impl State {...@@ -716,7 +904,7 @@ impl State {
716 .iter()904 .iter()
717 .position(|listed| *listed == scope)905 .position(|listed| *listed == scope)
718 .unwrap_or_default();906 .unwrap_or_default();
719 if let Some(index) = choice(&mut self.ui, "tag scope", current, &names, INSIDE) {907 if let Some(index) = choice(&mut self.ui, "tag scope", "Search", current, &names, INSIDE) {
720 scope = TagScope::ALL[index];908 scope = TagScope::ALL[index];
721 }909 }
722 self.search.pane = Some(Pane::Tags {910 self.search.pane = Some(Pane::Tags {
crates/snowbound/src/recording.rs+125-40
...@@ -22,6 +22,12 @@ pub(crate) enum Media {...@@ -22,6 +22,12 @@ pub(crate) enum Media {
22 video: bool,22 video: bool,
23 path: std::path::PathBuf,23 path: std::path::PathBuf,
24 },24 },
25 /// A stopped recording's file being finished, which goes on the page once ready.
26 Saving {
27 video: bool,
28 path: std::path::PathBuf,
29 job: std::thread::JoinHandle<Result<Vec<u8>, String>>,
30 },
25 Playing(Box<Playback>),31 Playing(Box<Playback>),
26}32}
2733
...@@ -61,10 +67,11 @@ impl State {...@@ -61,10 +67,11 @@ impl State {
61 /// Record Audio or Record Video: starts recording at the caret, or stops the recording67 /// Record Audio or Record Video: starts recording at the caret, or stops the recording
62 /// under way.68 /// under way.
63 pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> {69 pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> {
64 if matches!(self.media, Media::Recording { .. }) {70 match self.media {
65 return self.stop_recording();71 Media::Recording { .. } => return self.stop_recording(false),
72 Media::Saving { .. } => return Ok(()),
73 _ => self.media = Media::Idle,
66 }74 }
67 self.media = Media::Idle;
68 let folder = std::env::temp_dir().join("Snowbound Recordings");75 let folder = std::env::temp_dir().join("Snowbound Recordings");
69 std::fs::create_dir_all(&folder)?;76 std::fs::create_dir_all(&folder)?;
70 let extension = if video { "avi" } else { "wav" };77 let extension = if video { "avi" } else { "wav" };
...@@ -92,7 +99,7 @@ impl State {...@@ -92,7 +99,7 @@ impl State {
92 let (id, response) = self.view.start_recording(&label)?;99 let (id, response) = self.view.start_recording(&label)?;
93 self.respond(response);100 self.respond(response);
94 if id.is_none() {101 if id.is_none() {
95 recorder.stop()?;102 recorder.stop()?()?;
96 return Ok(());103 return Ok(());
97 }104 }
98 self.media = Media::Recording {105 self.media = Media::Recording {
...@@ -103,33 +110,63 @@ impl State {...@@ -103,33 +110,63 @@ impl State {
103 Ok(())110 Ok(())
104 }111 }
105112
106 /// Stop: the recording under way goes on the page where it started.113 /// Stop: the recording under way stops, and goes on the page where it started once its
107 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {114 /// file is saved, which happens meanwhile; `wait` waits for it.
108 let Media::Recording {115 pub(crate) fn stop_recording(&mut self, wait: bool) -> Result<(), Box<dyn Error>> {
109 recorder,116 match std::mem::take(&mut self.media) {
110 video,117 Media::Recording {
111 path,118 recorder,
112 } = std::mem::take(&mut self.media)119 video,
113 else {120 path,
114 return Ok(());121 } => {
122 // Nothing written while the file saves links to the recording.
123 self.view.editor.pause_recording(true);
124 let finish = match recorder.stop() {
125 Ok(finish) => finish,
126 Err(detail) => {
127 platform::alert("Couldn't finish the recording", &detail);
128 return Ok(());
129 }
130 };
131 let recorded = path.clone();
132 let job = std::thread::spawn(move || {
133 finish()?;
134 let recorded = std::fs::read(&recorded).map_err(|error| error.to_string())?;
135 Ok(if video {
136 recorded
137 } else {
138 compress(&recorded).map_or(recorded, |(bytes, _)| bytes)
139 })
140 });
141 self.media = Media::Saving { video, path, job };
142 }
143 other => self.media = other,
144 }
145 match &self.media {
146 Media::Saving { job, .. } if wait || job.is_finished() => {}
147 _ => return Ok(()),
148 }
149 let Media::Saving { video, path, job } = std::mem::take(&mut self.media) else {
150 unreachable!("saving, as matched")
115 };151 };
152 let saved = job
153 .join()
154 .unwrap_or_else(|_| Err("Try recording again.".into()));
116 let Some(id) = self.view.editor.recording() else {155 let Some(id) = self.view.editor.recording() else {
117 return Ok(());156 return Ok(());
118 };157 };
119 if let Err(detail) = recorder.stop() {158 let bytes = match saved {
120 platform::alert("Couldn't finish the recording", &detail);159 Ok(bytes) => bytes,
121 return Ok(());160 Err(detail) => {
122 }161 platform::alert("Couldn't finish the recording", &detail);
123 let recorded = std::fs::read(&path)?;162 return Ok(());
124 let (bytes, duration_ms) = if video {
125 let duration = video::Movie::parse(&recorded).map(|movie| movie.duration_ms());
126 (recorded, duration)
127 } else {
128 match compress(&recorded) {
129 Some((bytes, duration)) => (bytes, Some(duration)),
130 None => (recorded, None),
131 }163 }
132 };164 };
165 let duration_ms = if video {
166 video::Movie::parse(&bytes).map(|movie| movie.duration_ms())
167 } else {
168 Wave::parse(&bytes).map(|wave| wave.duration_ms())
169 };
133 let preview = platform::file_icon(&path);170 let preview = platform::file_icon(&path);
134 std::fs::remove_file(&path)?;171 std::fs::remove_file(&path)?;
135 let title = self.view.editor.page()?.title;172 let title = self.view.editor.page()?.title;
...@@ -176,7 +213,7 @@ impl State {...@@ -176,7 +213,7 @@ impl State {
176 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's213 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's
177 /// player where Snowbound cannot play it.214 /// player where Snowbound cannot play it.
178 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {215 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {
179 if matches!(self.media, Media::Recording { .. }) {216 if matches!(self.media, Media::Recording { .. } | Media::Saving { .. }) {
180 return Ok(());217 return Ok(());
181 }218 }
182 self.media = Media::Idle;219 self.media = Media::Idle;
...@@ -233,18 +270,18 @@ impl State {...@@ -233,18 +270,18 @@ impl State {
233270
234 pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status {271 pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status {
235 let (recording, playing) = match &self.media {272 let (recording, playing) = match &self.media {
236 Media::Idle => (false, None),273 Media::Idle | Media::Saving { .. } => (false, None),
237 Media::Recording { .. } => (true, None),274 Media::Recording { .. } => (true, None),
238 Media::Playing(playback) => (false, Some(playback.player.playing())),275 Media::Playing(playback) => (false, Some(playback.player.playing())),
239 };276 };
240 let (enabled, checked) = match transport {277 let (enabled, checked) = match transport {
241 Transport::Pause => (278 Transport::Pause => (
242 recording || playing.is_some(),279 recording || playing.is_some(),
243 self.view.editor.recording_paused() || playing == Some(false),280 Some(self.view.editor.recording_paused() || playing == Some(false)),
244 ),281 ),
245 Transport::Stop => (recording || playing.is_some(), false),282 Transport::Stop => (recording || playing.is_some(), None),
246 Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), false),283 Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), None),
247 Transport::SeePlayback => (true, self.see_playback),284 Transport::SeePlayback => (true, Some(self.see_playback)),
248 };285 };
249 crate::commands::Status { enabled, checked }286 crate::commands::Status { enabled, checked }
250 }287 }
...@@ -253,8 +290,8 @@ impl State {...@@ -253,8 +290,8 @@ impl State {
253 pub(crate) fn run_transport(&mut self, transport: Transport) -> Result<(), Box<dyn Error>> {290 pub(crate) fn run_transport(&mut self, transport: Transport) -> Result<(), Box<dyn Error>> {
254 match (transport, &mut self.media) {291 match (transport, &mut self.media) {
255 (Transport::SeePlayback, _) => self.see_playback = !self.see_playback,292 (Transport::SeePlayback, _) => self.see_playback = !self.see_playback,
256 (Transport::Stop, Media::Recording { .. }) => self.stop_recording()?,293 (Transport::Stop, Media::Recording { .. }) => self.stop_recording(false)?,
257 (Transport::Stop, _) => self.media = Media::Idle,294 (Transport::Stop, Media::Playing(_)) => self.media = Media::Idle,
258 (Transport::Pause, Media::Recording { recorder, .. }) => {295 (Transport::Pause, Media::Recording { recorder, .. }) => {
259 let paused = !self.view.editor.recording_paused();296 let paused = !self.view.editor.recording_paused();
260 recorder.pause(paused);297 recorder.pause(paused);
...@@ -294,9 +331,14 @@ impl State {...@@ -294,9 +331,14 @@ impl State {
294 page: [f32; 4],331 page: [f32; 4],
295 ) -> Result<(), Box<dyn Error>> {332 ) -> Result<(), Box<dyn Error>> {
296 let row = theme.font_size * 2.0;333 let row = theme.font_size * 2.0;
334 if matches!(self.media, Media::Saving { .. }) {
335 self.stop_recording(false)?;
336 }
337 let mut played = None;
297 let (clock_text, picture) = match &mut self.media {338 let (clock_text, picture) = match &mut self.media {
298 Media::Idle => {339 Media::Idle => {
299 self.view.played = None;340 let response = self.view.set_played(None)?;
341 self.respond(response);
300 return Ok(());342 return Ok(());
301 }343 }
302 Media::Recording { video, .. } => {344 Media::Recording { video, .. } => {
...@@ -311,9 +353,18 @@ impl State {...@@ -311,9 +353,18 @@ impl State {
311 self.ui.wake_after(Duration::from_millis(250));353 self.ui.wake_after(Duration::from_millis(250));
312 (format!("{state} {}", clock(at)), None)354 (format!("{state} {}", clock(at)), None)
313 }355 }
356 Media::Saving { video, .. } => {
357 self.ui.wake_after(Duration::from_millis(100));
358 let saving = if *video {
359 "Saving video…"
360 } else {
361 "Saving audio…"
362 };
363 (saving.to_owned(), None)
364 }
314 Media::Playing(playback) => {365 Media::Playing(playback) => {
315 let at = playback.player.position_ms();366 let at = playback.player.position_ms();
316 self.view.played = playback367 played = playback
317 .recording368 .recording
318 .filter(|_| self.see_playback)369 .filter(|_| self.see_playback)
319 .and_then(|id| self.view.editor.played_note(id, at))370 .and_then(|id| self.view.editor.played_note(id, at))
...@@ -349,6 +400,8 @@ impl State {...@@ -349,6 +400,8 @@ impl State {
349 )400 )
350 }401 }
351 };402 };
403 let response = self.view.set_played(played)?;
404 self.respond(response);
352 let picture_height = picture405 let picture_height = picture
353 .as_ref()406 .as_ref()
354 .map_or(0.0, |image| image.size()[1] as f32 + 6.0);407 .map_or(0.0, |image| image.size()[1] as f32 + 6.0);
...@@ -388,14 +441,19 @@ impl State {...@@ -388,14 +441,19 @@ impl State {
388 ..Spec::default()441 ..Spec::default()
389 },442 },
390 );443 );
391 let paused = self.transport_status(Transport::Pause).checked;444 let paused = self.transport_status(Transport::Pause).checked == Some(true);
392 let playing = matches!(self.media, Media::Playing(_));445 let playing = matches!(self.media, Media::Playing(_));
393 let pause = match (playing, paused) {446 let pause = match (playing, paused) {
394 (true, true) => "Play",447 (true, true) => "Play",
395 (false, true) => "Resume",448 (false, true) => "Resume",
396 _ => "Pause",449 _ => "Pause",
397 };450 };
398 let mut buttons = vec![(pause, Transport::Pause)];451 let saving = matches!(self.media, Media::Saving { .. });
452 let mut buttons = if saving {
453 Vec::new()
454 } else {
455 vec![(pause, Transport::Pause)]
456 };
399 if playing {457 if playing {
400 buttons.extend([458 buttons.extend([
401 ("−10 min", Transport::Skip(-600)),459 ("−10 min", Transport::Skip(-600)),
...@@ -404,7 +462,7 @@ impl State {...@@ -404,7 +462,7 @@ impl State {
404 }462 }
405 let mut chosen = buttons463 let mut chosen = buttons
406 .into_iter()464 .into_iter()
407 .filter(|(label, _)| ui::button(&mut self.ui, label, label).clicked)465 .filter(|(label, transport)| self.transport_button(label, *transport))
408 .last()466 .last()
409 .map(|(_, transport)| transport);467 .map(|(_, transport)| transport);
410 let seek = self.seek(theme, &clock_text);468 let seek = self.seek(theme, &clock_text);
...@@ -416,7 +474,7 @@ impl State {...@@ -416,7 +474,7 @@ impl State {
416 ]);474 ]);
417 }475 }
418 for (label, transport) in after {476 for (label, transport) in after {
419 if ui::button(&mut self.ui, label, label).clicked {477 if self.transport_button(label, transport) {
420 chosen = Some(transport);478 chosen = Some(transport);
421 }479 }
422 }480 }
...@@ -424,7 +482,7 @@ impl State {...@@ -424,7 +482,7 @@ impl State {
424 {482 {
425 chosen = Some(Transport::SeePlayback);483 chosen = Some(Transport::SeePlayback);
426 }484 }
427 if ui::button(&mut self.ui, "stop", "Stop").clicked {485 if !saving && ui::button(&mut self.ui, "stop", "Stop").clicked {
428 chosen = Some(Transport::Stop);486 chosen = Some(Transport::Stop);
429 }487 }
430 self.ui.close();488 self.ui.close();
...@@ -440,6 +498,20 @@ impl State {...@@ -440,6 +498,20 @@ impl State {
440 Ok(())498 Ok(())
441 }499 }
442500
501 /// A transport button showing `label`, a skip named by its command's title, which the
502 /// label abbreviates; returns whether it was clicked.
503 fn transport_button(&mut self, label: &str, transport: Transport) -> bool {
504 let clicked = ui::button(&mut self.ui, label, label).clicked;
505 if matches!(transport, Transport::Skip(_))
506 && let Some(node) = self.ui.access(self.ui.id(label))
507 {
508 node.set_label(
509 crate::commands::command(crate::commands::Id::Transport(transport)).title,
510 );
511 }
512 clicked
513 }
514
443 /// The transport's clock, or while seeking the field taking the moment: Enter goes515 /// The transport's clock, or while seeking the field taking the moment: Enter goes
444 /// there, Escape or leaving the field keeps playing where it was. A click on the clock516 /// there, Escape or leaving the field keeps playing where it was. A click on the clock
445 /// seeks as Seek To does.517 /// seeks as Seek To does.
...@@ -466,10 +538,18 @@ impl State {...@@ -466,10 +538,18 @@ impl State {
466 text: Some(clock_text),538 text: Some(clock_text),
467 pad: [6.0, 0.0],539 pad: [6.0, 0.0],
468 center: true,540 center: true,
541 role: playing.then_some(accesskit::Role::Button),
469 ..Spec::default()542 ..Spec::default()
470 },543 },
471 )544 )
472 .clicked;545 .clicked;
546 if playing && let Some(node) = self.ui.access(self.ui.id("clock")) {
547 let title =
548 crate::commands::command(crate::commands::Id::Transport(Transport::SeekTo))
549 .title;
550 node.set_label(title.trim_end_matches('…'));
551 node.set_value(clock_text);
552 }
473 if clicked {553 if clicked {
474 let _ = self.run_transport(Transport::SeekTo);554 let _ = self.run_transport(Transport::SeekTo);
475 }555 }
...@@ -1045,5 +1125,10 @@ pub(crate) mod tests {...@@ -1045,5 +1125,10 @@ pub(crate) mod tests {
1045 assert_eq!(clock(8_400), "0:08");1125 assert_eq!(clock(8_400), "0:08");
1046 assert_eq!(clock(754_000), "12:34");1126 assert_eq!(clock(754_000), "12:34");
1047 assert_eq!(clock(3_723_000), "1:02:03");1127 assert_eq!(clock(3_723_000), "1:02:03");
1128 // Seek To takes the clock's own forms back.
1129 assert_eq!(moment("12:34"), Some(754_000));
1130 assert_eq!(moment(" 1:02:03 "), Some(3_723_000));
1131 assert_eq!(moment("45"), Some(45_000));
1132 assert_eq!(moment("1:x"), None);
1048 }1133 }
1049}1134}
crates/snowbound/src/rename.rs+1
...@@ -65,6 +65,7 @@ pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Optio...@@ -65,6 +65,7 @@ pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Optio
65 ..Spec::default()65 ..Spec::default()
66 },66 },
67 );67 );
68 crate::name(ui, field(), "Name");
68 signal.events.iter().find_map(|event| match event {69 signal.events.iter().find_map(|event| match event {
69 Event::Key {70 Event::Key {
70 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),71 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
crates/snowbound/src/screenshot.rs+57-18
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1//! `--screenshot`: the window drawn offscreen at a fixed size in each appearance. On macOS1//! `--screenshot`: the window drawn offscreen at a fixed size in each appearance. On macOS
2//! the parts AppKit draws over it, the traffic lights and the rounded corners, are painted2//! the parts AppKit draws over it, the traffic lights and the rounded corners, are painted
3//! in; elsewhere the app draws its whole window.3//! in; elsewhere the app draws its whole window. On macOS `SNOWBOUND_SCREENSHOT_SYSTEM=
4//! snow-leopard` draws it as a Mac OS X 10.6 window instead.
5
6#[cfg(target_os = "macos")]
7mod snow_leopard;
48
5use super::*;9use super::*;
6use winit::window::Theme as Appearance;10use winit::window::Theme as Appearance;
...@@ -9,6 +13,8 @@ use winit::window::Theme as Appearance;...@@ -9,6 +13,8 @@ use winit::window::Theme as Appearance;
9/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.13/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.
10const SIZE: [f32; 2] = [1440.0, 900.0];14const SIZE: [f32; 2] = [1440.0, 900.0];
11const SCALE: f32 = 2.0;15const SCALE: f32 = 2.0;
16/// The window's content as a Snow Leopard window, its default size.
17const SNOW_LEOPARD_SIZE: [f32; 2] = [1180.0, 760.0];
12/// The close, minimize and zoom buttons' fill and rim.18/// The close, minimize and zoom buttons' fill and rim.
13#[cfg(target_os = "macos")]19#[cfg(target_os = "macos")]
14const LIGHTS: [([u8; 3], [u8; 3]); 3] = [20const LIGHTS: [([u8; 3], [u8; 3]); 3] = [
...@@ -17,18 +23,40 @@ const LIGHTS: [([u8; 3], [u8; 3]); 3] = [...@@ -17,18 +23,40 @@ const LIGHTS: [([u8; 3], [u8; 3]); 3] = [
17 ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]),23 ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]),
18];24];
1925
26/// Whether `SNOWBOUND_SCREENSHOT_SYSTEM` asks for a Snow Leopard window.
27fn snow_leopard() -> bool {
28 cfg!(target_os = "macos")
29 && std::env::var_os("SNOWBOUND_SCREENSHOT_SYSTEM")
30 .is_some_and(|system| system == "snow-leopard")
31}
32
33/// Before the window opens: a Snow Leopard window lays the app out as on 10.6.
34pub(crate) fn prepare() {
35 #[cfg(target_os = "macos")]
36 if snow_leopard() {
37 crate::aqua::pretend();
38 }
39}
40
20impl State {41impl State {
21 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows.42 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows;
43 /// as a Snow Leopard window, `PREFIX-snow-leopard.png` and, not key,
44 /// `PREFIX-snow-leopard-other.png`, at 10.6's one pixel per point.
22 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {45 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {
23 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {46 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {
24 Ok(size) => size47 Ok(size) => size
25 .split_once('x')48 .split_once('x')
26 .and_then(|(width, height)| Some([width.parse().ok()?, height.parse().ok()?]))49 .and_then(|(width, height)| Some([width.parse().ok()?, height.parse().ok()?]))
27 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,50 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,
51 Err(_) if snow_leopard() => SNOW_LEOPARD_SIZE,
28 Err(_) => SIZE,52 Err(_) => SIZE,
29 };53 };
30 let most = self.renderer.max_texture_dimension() as f32;54 let most = self.renderer.max_texture_dimension() as f32;
31 let scale = SCALE.min(most / size[0].max(size[1]));55 let scale = if snow_leopard() {
56 1.0
57 } else {
58 SCALE.min(most / size[0].max(size[1]))
59 };
32 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);60 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);
33 self.renderer.clear_glyph_cache();61 self.renderer.clear_glyph_cache();
34 let response = self.view.scale_factor_changed(scale)?;62 let response = self.view.scale_factor_changed(scale)?;
...@@ -43,24 +71,14 @@ impl State {...@@ -43,24 +71,14 @@ impl State {
43 {71 {
44 self.view.editor.focus_outline(title.id)?;72 self.view.editor.focus_outline(title.id)?;
45 }73 }
74 #[cfg(target_os = "macos")]
75 if snow_leopard() {
76 return snow_leopard::screenshot(self, prefix, size);
77 }
46 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {78 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
47 self.window.set_theme(Some(appearance));79 self.window.set_theme(Some(appearance));
48 self.set_appearance(appearance);80 self.set_appearance(appearance);
49 // The page's box sizes the view whose pictures settle.81 self.settle_frame(size, scale)?;
50 self.layout(size, scale)?;
51 let paper = self.paper();
52 if let Some((scene, _)) = &mut self.view.scene {
53 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
54 }
55 // Later frames lay out with earlier frames' measurements while colours ease.
56 let deadline = Instant::now() + std::time::Duration::from_secs(2);
57 loop {
58 self.layout(size, scale)?;
59 if !self.ui.wants_frame() || Instant::now() > deadline {
60 break;
61 }
62 std::thread::sleep(std::time::Duration::from_millis(16));
63 }
64 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));82 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
65 self.snapshot(&path)?;83 self.snapshot(&path)?;
66 #[cfg(target_os = "macos")]84 #[cfg(target_os = "macos")]
...@@ -75,6 +93,27 @@ impl State {...@@ -75,6 +93,27 @@ impl State {
75 }93 }
76}94}
7795
96impl State {
97 /// Lays the frame out until pictures and eased colours settle.
98 fn settle_frame(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
99 // The page's box sizes the view whose pictures settle.
100 self.layout(size, scale)?;
101 let paper = self.paper();
102 if let Some((scene, _)) = &mut self.view.scene {
103 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
104 }
105 // Later frames lay out with earlier frames' measurements while colours ease.
106 let deadline = Instant::now() + std::time::Duration::from_secs(2);
107 loop {
108 self.layout(size, scale)?;
109 if !self.ui.wants_frame() || Instant::now() > deadline {
110 return Ok(());
111 }
112 std::thread::sleep(std::time::Duration::from_millis(16));
113 }
114 }
115}
116
78/// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners117/// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners
79/// to transparency and edges them with the window's hairline.118/// to transparency and edges them with the window's hairline.
80#[cfg(target_os = "macos")]119#[cfg(target_os = "macos")]
crates/snowbound/src/screenshot/snow_leopard.rs created+303
...@@ -0,0 +1,303 @@
1//! A Mac OS X 10.6 window around the app, drawn anywhere: AppKit's textured gradient
2//! through the title line, the toolbar's row and the tab row, the title and its document
3//! icon in Lucida Grande, the window buttons as sampled from 10.6, the rounded corners and
4//! the window's shadow. The app lays itself out as on 10.6 (`aqua::pretend`).
5
6use super::*;
7
8/// The title line's height.
9const TITLE_LINE: usize = 22;
10/// The textured gradient's greys as 10.6 draws them, key and not: the title line's top
11/// highlight, the gradient's first and last rows, and the window below it.
12const GREYS: [[f32; 4]; 2] = [[226.0, 208.0, 167.0, 167.0], [244.0, 237.0, 216.0, 216.0]];
13/// The window buttons, sampled with `remote.sh art`: three 14 pixels wide, 21 apart from
14/// 8 pixels in, 4 down.
15const BUTTONS: [&[u8]; 2] = [
16 include_bytes!("../../assets/snow-leopard/buttons-key.png"),
17 include_bytes!("../../assets/snow-leopard/buttons-other.png"),
18];
19/// The title's colour, key and not.
20const INK: [u8; 2] = [0, 128];
21/// The window's shadow, key and not, as 10.6 captures a window: the room around the window
22/// either side, above and below, then the shadow's softness as a box blur's radius, how
23/// far it falls and how dark it is at its darkest.
24const SHADOW: [([usize; 3], usize, usize, f32); 2] =
25 [([40, 25, 55], 14, 15, 0.5), ([20, 10, 30], 7, 10, 0.4)];
26
27pub(super) fn screenshot(
28 state: &mut State,
29 prefix: &Path,
30 size: [f32; 2],
31) -> Result<(), Box<dyn Error>> {
32 let title = state.window.title();
33 for (name, key) in [("snow-leopard", true), ("snow-leopard-other", false)] {
34 state.window.set_theme(Some(Appearance::Light));
35 state.set_appearance(Appearance::Light);
36 // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6.
37 state.ui.theme = theme(Appearance::Light, state.light_pages, true);
38 state.titlebar = [state.ui.theme.strip; 2];
39 state.ui.window_focused = key;
40 state.settle_frame(size, 1.0)?;
41 let content = state.capture()?;
42 let bar = title_bar(state, &title, size[0], key)?;
43 let [width, height] = [size[0] as usize, size[1] as usize];
44 let window = window(&content, &bar, [width, height], key)?;
45 let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key);
46 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
47 write_png(&path, canvas.map(|side| side as u32), &shadowed)?;
48 }
49 Ok(())
50}
51
52/// The title and its document icon, centred as AppKit centres them: premultiplied pixels
53/// as the renderer leaves them.
54fn title_bar(
55 state: &mut State,
56 title: &str,
57 width: f32,
58 key: bool,
59) -> Result<Vec<u8>, Box<dyn Error>> {
60 let mut chrome = ui::Ui::new(ui::Theme::light(), std::time::Duration::ZERO);
61 chrome.set_system_font("Lucida Grande");
62 chrome.begin([width, TITLE_LINE as f32], 1.0, Instant::now());
63 let text = chrome.measure(title)[0];
64 // AppKit centres the icon's 16 pixels, a gap of 4 and the title, 2 pixels to the left.
65 let x = ((width - 20.0 - text) / 2.0).round() - 2.0;
66 let icon = document_icon();
67 chrome.leaf(
68 "document",
69 Spec {
70 flags: Flags::FLOAT,
71 size: [px(16.0); 2],
72 position: [x, 3.0],
73 image: Some(&icon),
74 ..Spec::default()
75 },
76 );
77 let ink = INK[usize::from(!key)];
78 chrome.leaf(
79 "title",
80 Spec {
81 flags: Flags::FLOAT,
82 size: [fit(), px(TITLE_LINE as f32)],
83 position: [x + 20.0, -1.0],
84 text: Some(title),
85 color: Some(draw::srgb(ink, ink, ink)),
86 ..Spec::default()
87 },
88 );
89 chrome.end();
90 let interface = chrome.layers();
91 let layers: Vec<_> = interface
92 .iter()
93 .map(|layer| match layer {
94 ui::Layer::Primitives(primitives) => primitives.layer(1.0),
95 ui::Layer::Custom { .. } => unreachable!("The title bar has no custom boxes"),
96 })
97 .collect();
98 let content = state.surface.size;
99 state.surface.size = [width as u32, TITLE_LINE as u32];
100 let offscreen = state.surface.offscreen(&state.renderer)?;
101 let drawn = state
102 .renderer
103 .draw(&offscreen.target, state.surface.size, [0.0; 4], &layers)
104 .map_err(|error| format!("Drawing the title bar failed: {error:?}"));
105 let pixels = state.surface.read(&state.renderer, offscreen);
106 state.surface.size = content;
107 drawn?;
108 pixels
109}
110
111/// A document's icon at 16 pixels: a page with its corner folded.
112fn document_icon() -> draw::RasterImage {
113 let pixels = (0..16)
114 .flat_map(|y| (0..16).map(move |x| (x, y)))
115 .flat_map(|(x, y): (i32, i32)| {
116 let [left, right, top, bottom, fold] = [2, 13, 0, 15, 4];
117 let folded = x - (right - fold) + (fold - y);
118 let grey = if x < left || x > right || y < top || y > bottom || folded > fold {
119 None
120 } else if x == left || x == right || y == top || y == bottom || folded == fold {
121 Some(150)
122 } else if folded >= 0 && x > right - fold && y < fold {
123 Some(226)
124 } else {
125 Some(236 + y as u8)
126 };
127 grey.map_or([0; 4], |grey| [grey, grey, grey, 255])
128 })
129 .collect();
130 draw::RasterImage::new([16, 16], pixels).expect("The icon is a valid image")
131}
132
133/// sRGB bytes to and from linear light.
134fn decode(value: f32) -> f32 {
135 if value <= 0.04045 {
136 value / 12.92
137 } else {
138 ((value + 0.055) / 1.055).powf(2.4)
139 }
140}
141
142fn encode(value: f32) -> f32 {
143 if value <= 0.0031308 {
144 value * 12.92
145 } else {
146 1.055 * value.powf(1.0 / 2.4) - 0.055
147 }
148}
149
150/// Lays renderer pixels over `under`, straight sRGB, as the window server composites a
151/// transparent surface: premultiplied in sRGB.
152fn composite(under: &mut [f32; 4], rendered: &[u8]) {
153 let alpha = f32::from(rendered[3]) / 255.0;
154 if alpha == 0.0 {
155 return;
156 }
157 for channel in 0..3 {
158 let linear = decode(f32::from(rendered[channel]) / 255.0) / alpha;
159 let over = encode(linear.min(1.0)) * 255.0;
160 under[channel] = over * alpha + under[channel] * (1.0 - alpha);
161 }
162}
163
164/// Straight-alpha sRGB `source` over `under`.
165fn over(under: &mut [f32; 4], source: [u8; 4]) {
166 let alpha = f32::from(source[3]) / 255.0;
167 for channel in 0..3 {
168 under[channel] = f32::from(source[channel]) * alpha + under[channel] * (1.0 - alpha);
169 }
170}
171
172/// The window: its gradient and grey, the app's `content` and the title `bar` over it, the
173/// buttons, and its corners cut. Straight sRGB, `size` being the content's.
174fn window(
175 content: &[u8],
176 bar: &[u8],
177 [width, height]: [usize; 2],
178 key: bool,
179) -> Result<Vec<[f32; 4]>, Box<dyn Error>> {
180 let [highlight, top, bottom, body] = GREYS[usize::from(!key)];
181 // The gradient runs from under the highlight to the top content border's last row.
182 let end = TITLE_LINE + (crate::TITLE + crate::TAB_ROW) as usize - 1;
183 let total = height + TITLE_LINE;
184 let mut pixels: Vec<[f32; 4]> = (0..total)
185 .flat_map(|y| {
186 let grey = match y {
187 0 => highlight,
188 _ if y <= end => top + (bottom - top) * (y - 1) as f32 / (end - 1) as f32,
189 _ => body,
190 };
191 std::iter::repeat_n([grey, grey, grey, 255.0], width)
192 })
193 .collect();
194 for (y, row) in content.chunks_exact(width * 4).enumerate() {
195 for (x, pixel) in row.chunks_exact(4).enumerate() {
196 composite(&mut pixels[(y + TITLE_LINE) * width + x], pixel);
197 }
198 }
199 for (y, row) in bar.chunks_exact(width * 4).enumerate() {
200 for (x, pixel) in row.chunks_exact(4).enumerate() {
201 composite(&mut pixels[y * width + x], pixel);
202 }
203 }
204 let mut decoder = png::Decoder::new(std::io::Cursor::new(BUTTONS[usize::from(!key)]));
205 decoder.set_transformations(png::Transformations::normalize_to_color8());
206 let mut reader = decoder.read_info()?;
207 let mut buttons = vec![0; reader.output_buffer_size().ok_or("Buttons too large")?];
208 let info = reader.next_frame(&mut buttons)?;
209 for (y, row) in buttons.chunks_exact(info.width as usize * 4).enumerate() {
210 for (x, pixel) in row.chunks_exact(4).enumerate() {
211 let at = (4 + y) * width + 8 + 21 * (x / 14) + x % 14;
212 over(&mut pixels[at], pixel.try_into()?);
213 }
214 }
215 // AppKit rounds the top corners by about 4.5 pixels, a textured window's bottom ones by
216 // about 3.5.
217 let inside = |distance: f32| (0.5 - distance).clamp(0.0, 1.0);
218 for (index, pixel) in pixels.iter_mut().enumerate() {
219 let [x, y] = [(index % width) as f32 + 0.5, (index / width) as f32 + 0.5];
220 let radius = if y < total as f32 / 2.0 { 4.5 } else { 3.5 };
221 let [dx, dy] = [
222 (x - width as f32 / 2.0).abs() - (width as f32 / 2.0 - radius),
223 (y - total as f32 / 2.0).abs() - (total as f32 / 2.0 - radius),
224 ];
225 let edge = dx.max(0.0).hypot(dy.max(0.0)) + dx.max(dy).min(0.0) - radius;
226 pixel[3] *= inside(edge);
227 }
228 Ok(pixels)
229}
230
231/// The window over its shadow on a transparent canvas, and the canvas's size, as straight
232/// RGBA bytes.
233fn shadow(window: &[[f32; 4]], [width, height]: [usize; 2], key: bool) -> ([usize; 2], Vec<u8>) {
234 let (margin, blur, fall, darkest) = SHADOW[usize::from(!key)];
235 let canvas = [width + 2 * margin[0], height + margin[1] + margin[2]];
236 let mut shade = vec![0.0f32; canvas[0] * canvas[1]];
237 for y in 0..height {
238 for x in 0..width {
239 let [cx, cy] = [x + margin[0], y + margin[1] + fall];
240 if cy < canvas[1] {
241 shade[cy * canvas[0] + cx] = window[y * width + x][3] / 255.0;
242 }
243 }
244 }
245 // Three box blurs each way come close to a Gaussian.
246 for _ in 0..3 {
247 box_blur(&mut shade, canvas, blur, true);
248 box_blur(&mut shade, canvas, blur, false);
249 }
250 let mut out = Vec::with_capacity(canvas[0] * canvas[1] * 4);
251 for y in 0..canvas[1] {
252 for x in 0..canvas[0] {
253 let shadow = shade[y * canvas[0] + x] * darkest;
254 let pixel = x
255 .checked_sub(margin[0])
256 .zip(y.checked_sub(margin[1]))
257 .filter(|&(wx, wy)| wx < width && wy < height)
258 .map_or([0.0; 4], |(wx, wy)| window[wy * width + wx]);
259 let alpha = pixel[3] / 255.0;
260 let total = alpha + shadow * (1.0 - alpha);
261 let color = |channel: usize| {
262 if total == 0.0 {
263 0
264 } else {
265 (pixel[channel] * alpha / total).round() as u8
266 }
267 };
268 out.extend([color(0), color(1), color(2), (total * 255.0).round() as u8]);
269 }
270 }
271 (canvas, out)
272}
273
274fn box_blur(values: &mut [f32], [width, height]: [usize; 2], radius: usize, rows: bool) {
275 let [lines, length] = if rows {
276 [height, width]
277 } else {
278 [width, height]
279 };
280 let at = |line: usize, along: usize| {
281 if rows {
282 line * width + along
283 } else {
284 along * width + line
285 }
286 };
287 let mut line_values = vec![0.0; length];
288 for line in 0..lines {
289 for (along, value) in line_values.iter_mut().enumerate() {
290 *value = values[at(line, along)];
291 }
292 let mut sum: f32 = line_values[..radius.min(length)].iter().sum();
293 for along in 0..length {
294 if along + radius < length {
295 sum += line_values[along + radius];
296 }
297 if along > radius {
298 sum -= line_values[along - radius - 1];
299 }
300 values[at(line, along)] = sum / (2 * radius + 1) as f32;
301 }
302 }
303}
crates/snowbound/src/search.rs+41-7
...@@ -3,7 +3,8 @@...@@ -3,7 +3,8 @@
3//! its own, from the section files and the open section's replica.3//! its own, from the section files and the open section's replica.
44
5use crate::pane::Pane;5use crate::pane::Pane;
6use crate::{Command, Library, State, Theme, art, commands, lap, page};6use crate::{Command, Library, State, Theme, art, commands, lap, name, page};
7use accesskit::Role;
7use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match};8use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match};
8use notebook::Replica;9use notebook::Replica;
9use onestore::ExGuid;10use onestore::ExGuid;
...@@ -735,9 +736,11 @@ impl State {...@@ -735,9 +736,11 @@ impl State {
735 Spec {736 Spec {
736 size: [fill(), px(ui::shell::TOOL)],737 size: [fill(), px(ui::shell::TOOL)],
737 pad: [4.0, 0.0],738 pad: [4.0, 0.0],
739 role: Some(Role::SearchInput),
738 ..Spec::default()740 ..Spec::default()
739 },741 },
740 );742 );
743 name(&mut self.ui, field(), "Search");
741 if signal.pressed && !searching {744 if signal.pressed && !searching {
742 self.start_search();745 self.start_search();
743 }746 }
...@@ -755,12 +758,18 @@ impl State {...@@ -755,12 +758,18 @@ impl State {
755758
756 fn scope_button(&mut self, theme: &Theme) {759 fn scope_button(&mut self, theme: &Theme) {
757 let button = self.ui.id("scope");760 let button = self.ui.id("scope");
758 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed761 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, None).pressed
759 {762 {
760 let rect = self.ui.rect(button).unwrap_or_default();763 let rect = self.ui.rect(button).unwrap_or_default();
761 self.search.anchor = Some(Anchor::Below(rect));764 self.search.anchor = Some(Anchor::Below(rect));
762 self.ui.open_popup(scope_menu());765 self.ui.open_popup(scope_menu());
763 }766 }
767 let open = self.ui.popup_open(scope_menu());
768 if let Some(node) = self.ui.access(button) {
769 node.set_label("Search In");
770 node.set_has_popup(accesskit::HasPopup::Menu);
771 node.set_expanded(open);
772 }
764 }773 }
765774
766 /// OneNote's "Search In:" menu.775 /// OneNote's "Search In:" menu.
...@@ -785,7 +794,7 @@ impl State {...@@ -785,7 +794,7 @@ impl State {
785 .enumerate()794 .enumerate()
786 .map(|(index, scope)| ui::popup::Item {795 .map(|(index, scope)| ui::popup::Item {
787 text: scope.name(),796 text: scope.name(),
788 checked: *scope == current,797 checked: Some(*scope == current),
789 separated: index == 0,798 separated: index == 0,
790 ..Default::default()799 ..Default::default()
791 }),800 }),
...@@ -854,9 +863,11 @@ impl State {...@@ -854,9 +863,11 @@ impl State {
854 pad: [4.0, 4.0],863 pad: [4.0, 4.0],
855 gap: 4.0,864 gap: 4.0,
856 anchor: Some(anchor),865 anchor: Some(anchor),
866 role: Some(Role::Dialog),
857 ..Spec::default()867 ..Spec::default()
858 },868 },
859 );869 );
870 name(&mut self.ui, results(), "Search");
860 self.ui.open(871 self.ui.open(
861 "box",872 "box",
862 Spec {873 Spec {
...@@ -884,9 +895,11 @@ impl State {...@@ -884,9 +895,11 @@ impl State {
884 Spec {895 Spec {
885 size: [fill(), px(ui::shell::TOOL)],896 size: [fill(), px(ui::shell::TOOL)],
886 pad: [4.0, 0.0],897 pad: [4.0, 0.0],
898 role: Some(Role::SearchInput),
887 ..Spec::default()899 ..Spec::default()
888 },900 },
889 );901 );
902 name(&mut self.ui, field(), "Search");
890 self.scope_button(theme);903 self.scope_button(theme);
891 self.ui.close();904 self.ui.close();
892 let query = self.search.query.trim().to_owned();905 let query = self.search.query.trim().to_owned();
...@@ -935,6 +948,7 @@ impl State {...@@ -935,6 +948,7 @@ impl State {
935 size: [fit(), px(22.0)],948 size: [fit(), px(22.0)],
936 text: Some(&scope),949 text: Some(&scope),
937 color: Some(theme.accent),950 color: Some(theme.accent),
951 role: Some(Role::Button),
938 ..Spec::default()952 ..Spec::default()
939 },953 },
940 )954 )
...@@ -988,6 +1002,7 @@ impl State {...@@ -988,6 +1002,7 @@ impl State {
988 Spec {1002 Spec {
989 size: [fill(), px(view)],1003 size: [fill(), px(view)],
990 fill: Some(theme.popup),1004 fill: Some(theme.popup),
1005 role: Some(Role::ListBox),
991 ..Spec::default()1006 ..Spec::default()
992 },1007 },
993 ui::List {1008 ui::List {
...@@ -998,6 +1013,10 @@ impl State {...@@ -998,6 +1013,10 @@ impl State {
998 },1013 },
999 &mut self.search.selected,1014 &mut self.search.selected,
1000 |ui, row| {1015 |ui, row| {
1016 if let Some(node) = ui.access(results().child("rows").child(row.key)) {
1017 node.set_role(Role::ListBoxOption);
1018 node.set_selected(row.selected);
1019 }
1001 let found = &found[row.index];1020 let found = &found[row.index];
1002 if row.index == 0 || row.index == titled {1021 if row.index == 0 || row.index == titled {
1003 let heading = if found.in_title {1022 let heading = if found.in_title {
...@@ -1022,6 +1041,11 @@ impl State {...@@ -1022,6 +1041,11 @@ impl State {
1022 result_row(ui, theme, found, &places[row.index], row.selected);1041 result_row(ui, theme, found, &places[row.index], row.selected);
1023 },1042 },
1024 );1043 );
1044 if let Some(key) = self.search.selected
1045 && let Some(node) = self.ui.access(field())
1046 {
1047 node.set_active_descendant(results().child("rows").child(key).node());
1048 }
1025 // Arrows show the page they select, as OneNote previews it.1049 // Arrows show the page they select, as OneNote previews it.
1026 if before != self.search.selected1050 if before != self.search.selected
1027 && keys.iter().any(|key| *key != NamedKey::Enter)1051 && keys.iter().any(|key| *key != NamedKey::Enter)
...@@ -1053,6 +1077,7 @@ impl State {...@@ -1053,6 +1077,7 @@ impl State {
1053 text: Some(&link),1077 text: Some(&link),
1054 color: Some(theme.accent),1078 color: Some(theme.accent),
1055 pad: [6.0, 0.0],1079 pad: [6.0, 0.0],
1080 role: Some(Role::Link),
1056 ..Spec::default()1081 ..Spec::default()
1057 },1082 },
1058 )1083 )
...@@ -1106,16 +1131,20 @@ impl State {...@@ -1106,16 +1131,20 @@ impl State {
1106 );1131 );
1107 let mut step = None;1132 let mut step = None;
1108 if count > 0 {1133 if count > 0 {
1109 if ui::shell::tool_button(&mut self.ui, "previous", art::CHEVRON_UP, theme.text, false)1134 if ui::shell::tool_button(&mut self.ui, "previous", art::CHEVRON_UP, theme.text, None)
1110 .clicked1135 .clicked
1111 {1136 {
1112 step = Some(false);1137 step = Some(false);
1113 }1138 }
1114 if ui::shell::tool_button(&mut self.ui, "next", ui::shell::CHEVRON, theme.text, false)1139 let button = self.ui.id("previous");
1140 name(&mut self.ui, button, "Previous Match");
1141 if ui::shell::tool_button(&mut self.ui, "next", ui::shell::CHEVRON, theme.text, None)
1115 .clicked1142 .clicked
1116 {1143 {
1117 step = Some(true);1144 step = Some(true);
1118 }1145 }
1146 let button = self.ui.id("next");
1147 name(&mut self.ui, button, "Next Match");
1119 }1148 }
1120 ui::text_field(1149 ui::text_field(
1121 &mut self.ui,1150 &mut self.ui,
...@@ -1125,14 +1154,19 @@ impl State {...@@ -1125,14 +1154,19 @@ impl State {
1125 Spec {1154 Spec {
1126 size: [fill(), px(ui::shell::TOOL)],1155 size: [fill(), px(ui::shell::TOOL)],
1127 pad: [4.0, 0.0],1156 pad: [4.0, 0.0],
1157 role: Some(Role::SearchInput),
1128 ..Spec::default()1158 ..Spec::default()
1129 },1159 },
1130 );1160 );
1161 name(&mut self.ui, find_field(), "Find on Page");
1131 if std::mem::take(&mut self.search.claim) {1162 if std::mem::take(&mut self.search.claim) {
1132 self.ui.set_focus(Some(find_field()));1163 self.ui.set_focus(Some(find_field()));
1133 }1164 }
1134 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked1165 let close =
1135 {1166 ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None).clicked;
1167 let button = self.ui.id("close");
1168 name(&mut self.ui, button, "Close");
1169 if close {
1136 self.end_search();1170 self.end_search();
1137 return Ok(());1171 return Ok(());
1138 }1172 }
crates/snowbound/src/server.rs created+1033
...@@ -0,0 +1,1033 @@
1//! Open Notebook from Server, as the Finder's Connect to Server reaches a share: an address,
2//! a sign-in, then a folder on the share, all through Snowbound's own SMB client, which works
3//! where the system's file sharing can't reach the server.
4
5use crate::library::{Library, Login, Mount};
6use crate::{State, art, platform};
7use accesskit::Role;
8use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal};
9use std::{io, path::Path, sync::mpsc, time::Duration};
10use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px};
11use winit::keyboard::NamedKey;
12
13const WIDTH: f32 = 480.0;
14/// The rows the folder list shows at once.
15const ROWS: f32 = 9.0;
16/// How long the server may take to answer before it counts as unreachable.
17const TIMEOUT: Duration = Duration::from_secs(10);
18/// The most entries a folder lists.
19const LIMIT: usize = 10_000;
20
21#[derive(Clone, Copy, Debug, PartialEq)]
22enum Step {
23 Address,
24 SignIn,
25 Browse,
26}
27
28/// The dialog's fields and where it has reached.
29pub struct Connect {
30 step: Step,
31 address: String,
32 user: String,
33 password: String,
34 domain: String,
35 guest: bool,
36 /// Whether to keep the password once it signs in; `None` where nothing can keep it.
37 remember: Option<bool>,
38 /// The share and folder shown, or to be asked for once the address reads.
39 mount: Option<Mount>,
40 listing: Listing,
41 status: Status,
42 /// The latest request; replies to earlier ones are dropped.
43 asked: u64,
44 /// A notebook listed as open that could not sign in, which opens once it lists.
45 reopen: bool,
46 replies: (mpsc::Sender<Reply>, mpsc::Receiver<Reply>),
47}
48
49/// What a folder holds, or a server's shares.
50#[derive(Debug, Default, PartialEq)]
51pub struct Listing {
52 /// Shares on the server, or folders in the folder.
53 folders: Vec<String>,
54 /// Section files in the folder.
55 sections: Vec<String>,
56 /// The folder holds a notebook's table of contents.
57 toc: bool,
58}
59
60#[derive(Debug, PartialEq)]
61enum Status {
62 Idle,
63 Waiting(&'static str),
64 Failed(String),
65}
66
67/// Lists a share folder, or the server's shares, signed in as `login`.
68pub struct Request {
69 asked: u64,
70 mount: Mount,
71 login: Login,
72}
73
74pub struct Reply {
75 asked: u64,
76 mount: Mount,
77 listed: io::Result<Listing>,
78}
79
80/// Where the dialog's requests go: the embedded SMB client, or a fake in tests.
81pub trait Reach {
82 fn shares(&self, mount: &Mount, login: &Login) -> io::Result<Vec<String>>;
83 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>>;
84}
85
86struct Embedded;
87
88fn credentials(login: &Login) -> Credentials<'_> {
89 Credentials {
90 username: &login.user,
91 password: &login.password,
92 domain: &login.domain,
93 }
94}
95
96impl Reach for Embedded {
97 fn shares(&self, mount: &Mount, login: &Login) -> io::Result<Vec<String>> {
98 notebook::smb::shares(&mount.endpoint(), credentials(login), TIMEOUT)
99 }
100
101 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> {
102 Client::connect(&mount.endpoint(), &mount.share, credentials(login), TIMEOUT)?
103 .read_dir(&mount.root, LIMIT)
104 }
105}
106
107impl Request {
108 pub fn run(self, reach: &impl Reach) -> Reply {
109 let listed = if self.mount.share.is_empty() {
110 reach.shares(&self.mount, &self.login).map(|mut shares| {
111 shares.sort_by_key(|name| name.to_lowercase());
112 Listing {
113 folders: shares,
114 ..Listing::default()
115 }
116 })
117 } else {
118 reach.list(&self.mount, &self.login).map(listing)
119 };
120 Reply {
121 asked: self.asked,
122 mount: self.mount,
123 listed,
124 }
125 }
126}
127
128/// A folder's entries as the browser lists them: folders to go into and the sections that
129/// make it a notebook, leaving out hidden folders and OneNote's recycle bin.
130fn listing(entries: Vec<DirectoryEntry>) -> Listing {
131 let extension = |name: &str, wanted: &str| {
132 Path::new(name)
133 .extension()
134 .is_some_and(|extension| extension.eq_ignore_ascii_case(wanted))
135 };
136 let mut listing = Listing {
137 toc: entries
138 .iter()
139 .any(|entry| extension(&entry.name, "onetoc2")),
140 ..Listing::default()
141 };
142 for entry in entries {
143 if entry.name.starts_with('.') || crate::library::recycle_bin(&entry.name) {
144 continue;
145 }
146 if entry.attributes & 0x10 != 0 {
147 listing.folders.push(entry.name);
148 } else if extension(&entry.name, "one") {
149 listing.sections.push(entry.name);
150 }
151 }
152 listing.folders.sort_by_key(|name| name.to_lowercase());
153 listing.sections.sort_by_key(|name| name.to_lowercase());
154 listing
155}
156
157/// Why connecting to `mount` failed, and what to do about it.
158pub fn refusal(error: &io::Error, mount: &Mount, guest: bool) -> String {
159 let server = mount.host();
160 match Refusal::of(error) {
161 Refusal::Unreachable => format!(
162 "Can\u{2019}t reach \u{201c}{server}\u{201d}. Check the address and that the server is on this network."
163 ),
164 Refusal::Smb1 => format!(
165 "\u{201c}{server}\u{201d} only uses SMB1, an old version of file sharing Snowbound doesn\u{2019}t support. Turn on SMB2 or later on the server."
166 ),
167 Refusal::SignIn if guest => format!(
168 "\u{201c}{server}\u{201d} doesn\u{2019}t allow guests. Sign in with a name and password."
169 ),
170 Refusal::SignIn => "The name or password is incorrect.".to_owned(),
171 Refusal::NoShare => format!(
172 "\u{201c}{server}\u{201d} has no shared folder named \u{201c}{}\u{201d}. Check the address.",
173 mount.share
174 ),
175 Refusal::Denied => format!(
176 "This account can\u{2019}t open \u{201c}{}\u{201d}. Sign in with another account.",
177 mount
178 .root
179 .rsplit('/')
180 .next()
181 .filter(|name| !name.is_empty())
182 .unwrap_or(&mount.share)
183 ),
184 Refusal::NoFolder => format!(
185 "\u{201c}{}\u{201d} isn\u{2019}t on the share. Check the address.",
186 mount.root
187 ),
188 Refusal::Other => {
189 eprintln!("Connecting to {server}: {error}");
190 format!("Couldn\u{2019}t connect to \u{201c}{server}\u{201d}. Try again.")
191 }
192 }
193}
194
195impl Connect {
196 pub fn new(address: String, remember: Option<bool>) -> Self {
197 Self {
198 step: Step::Address,
199 address,
200 user: String::new(),
201 password: String::new(),
202 domain: String::new(),
203 guest: false,
204 remember,
205 mount: None,
206 listing: Listing::default(),
207 status: Status::Idle,
208 asked: 0,
209 reopen: false,
210 replies: mpsc::channel(),
211 }
212 }
213
214 /// The account the sign-in names.
215 pub fn login(&self) -> Login {
216 match self.guest {
217 true => Login {
218 user: String::new(),
219 password: String::new(),
220 domain: String::new(),
221 },
222 false => Login {
223 user: self.user.trim().to_owned(),
224 password: self.password.clone(),
225 domain: self.domain.trim().to_owned(),
226 },
227 }
228 }
229
230 /// Connect: reads the address and asks for a sign-in, or signs in and lists the address.
231 pub fn submit(&mut self) -> Option<Request> {
232 match self.step {
233 Step::Address => {
234 let Some(mount) = Mount::from_address(&self.address) else {
235 self.status = Status::Failed(
236 "Enter an address like smb://server/share/folder.".to_owned(),
237 );
238 return None;
239 };
240 if let Some(user) = &mount.user {
241 self.user = user.clone();
242 self.domain = mount.domain.clone();
243 }
244 self.mount = Some(mount);
245 self.step = Step::SignIn;
246 self.status = Status::Idle;
247 None
248 }
249 Step::SignIn if !self.guest && self.user.trim().is_empty() => {
250 self.status = Status::Failed("Enter your name, or connect as a guest.".to_owned());
251 None
252 }
253 Step::SignIn => {
254 let mut mount = self.mount.clone()?;
255 let login = self.login();
256 mount.user = (!login.user.is_empty()).then(|| login.user.clone());
257 mount.domain = login.domain;
258 Some(self.request(mount, "Connecting\u{2026}"))
259 }
260 Step::Browse => None,
261 }
262 }
263
264 fn request(&mut self, mount: Mount, waiting: &'static str) -> Request {
265 self.asked += 1;
266 self.status = Status::Waiting(waiting);
267 Request {
268 asked: self.asked,
269 mount,
270 login: self.login(),
271 }
272 }
273
274 /// Goes into the share or folder listed `index`th.
275 pub fn enter(&mut self, index: usize) -> Option<Request> {
276 let mut mount = self.mount.clone()?;
277 let name = self.listing.folders.get(index)?;
278 if mount.share.is_empty() {
279 mount.share = name.clone();
280 } else if mount.root.is_empty() {
281 mount.root = name.clone();
282 } else {
283 mount.root = format!("{}/{name}", mount.root);
284 }
285 Some(self.request(mount, "Loading\u{2026}"))
286 }
287
288 /// Goes up to the folder holding the one shown, or from a share to the server's shares.
289 pub fn up(&mut self) -> Option<Request> {
290 let mut mount = self.mount.clone()?;
291 match mount.root.rsplit_once('/') {
292 Some((parent, _)) => mount.root = parent.to_owned(),
293 None if !mount.root.is_empty() => mount.root.clear(),
294 None if !mount.share.is_empty() => mount.share.clear(),
295 None => return None,
296 }
297 Some(self.request(mount, "Loading\u{2026}"))
298 }
299
300 /// Takes a request's outcome; whether it was the sign-in's, which the password may now
301 /// be kept for.
302 pub fn receive(&mut self, reply: Reply) -> bool {
303 if reply.asked != self.asked {
304 return false;
305 }
306 match reply.listed {
307 Ok(listing) => {
308 let signed_in = self.step == Step::SignIn;
309 self.step = Step::Browse;
310 self.listing = listing;
311 self.mount = Some(reply.mount);
312 self.status = Status::Idle;
313 signed_in
314 }
315 Err(error) => {
316 self.status = Status::Failed(refusal(&error, &reply.mount, self.guest));
317 // A share or folder the address named, or the account, is for the sign-in to fix.
318 if matches!(Refusal::of(&error), Refusal::SignIn | Refusal::Denied)
319 || self.step != Step::Browse
320 {
321 self.step = Step::SignIn;
322 self.password.clear();
323 }
324 false
325 }
326 }
327 }
328
329 /// The notebook Open opens: the folder shown, where it holds sections.
330 pub fn opens(&self) -> Option<(Mount, Login)> {
331 let mount = self.mount.as_ref()?;
332 let notebook = self.listing.toc || !self.listing.sections.is_empty();
333 (self.step == Step::Browse && !mount.share.is_empty() && notebook)
334 .then(|| (mount.clone(), self.login()))
335 }
336}
337
338fn id() -> Id {
339 Id::ROOT.child("server")
340}
341
342fn address_field() -> Id {
343 id().child("address")
344}
345
346fn user_field() -> Id {
347 id().child("user")
348}
349
350fn password_field() -> Id {
351 id().child("password")
352}
353
354fn domain_field() -> Id {
355 id().child("domain")
356}
357
358impl State {
359 /// Opens the dialog; on `location`, a notebook opened from its server that couldn't sign
360 /// in, at its sign-in with any password the keychain keeps tried first.
361 pub(crate) fn open_server(&mut self, location: Option<&str>) {
362 let mut connect = Connect::new(
363 location.unwrap_or_default().to_owned(),
364 platform::remember_label().map(|_| false),
365 );
366 let mut request = None;
367 if location.is_some() {
368 connect.reopen = true;
369 connect.submit();
370 if let Some(mount) = connect.mount.clone() {
371 let login = match mount.user {
372 Some(_) => platform::smb_login(&mount),
373 None => {
374 connect.guest = true;
375 Ok(Login::guest(&mount))
376 }
377 };
378 match login {
379 Ok(login) => {
380 connect.password = login.password;
381 request = connect.submit();
382 }
383 Err(reason) => connect.status = Status::Failed(reason),
384 }
385 }
386 }
387 let focus = match connect.step {
388 Step::Address => address_field(),
389 _ if connect.user.is_empty() => user_field(),
390 _ => password_field(),
391 };
392 self.server = Some(connect);
393 if let Some(request) = request {
394 self.ask(request);
395 }
396 self.ui.open_popup(id());
397 self.ui.focus_all(focus);
398 }
399
400 /// Runs `request` off the frame, which wakes when it is answered.
401 fn ask(&mut self, request: Request) {
402 let Some(connect) = &self.server else {
403 return;
404 };
405 let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone());
406 std::thread::spawn(move || {
407 let _ = replies.send(request.run(&Embedded));
408 redraw.wake();
409 });
410 }
411
412 /// Opens the notebook at `mount` through the embedded client signed in as `login`, and
413 /// closes the dialog.
414 fn open_from_server(&mut self, mount: Mount, login: Login) {
415 self.ui.close_popup(id());
416 self.server = None;
417 let location = mount.url();
418 self.open_notebook_with(location, None, move |location, cache| {
419 Library::on_share(location, mount, login, cache)
420 });
421 }
422
423 /// Builds the dialog while it is open.
424 pub(crate) fn server_dialog(&mut self) {
425 let Some(connect) = &mut self.server else {
426 return;
427 };
428 if !self.ui.popup_open(id()) {
429 self.server = None;
430 return;
431 }
432 let replies: Vec<Reply> = connect.replies.1.try_iter().collect();
433 for reply in replies {
434 if connect.receive(reply)
435 && connect.remember == Some(true)
436 && !connect.guest
437 && let (Some(mount), login) = (&connect.mount, connect.login())
438 && let Err(error) = platform::save_login(mount, &login)
439 {
440 eprintln!("Keeping the password for {}: {error}", mount.host());
441 }
442 }
443 if connect.reopen
444 && let Some((mount, login)) = connect.opens()
445 {
446 return self.open_from_server(mount, login);
447 }
448 let ui = &mut self.ui;
449 let theme = ui.theme.clone();
450 let row = theme.font_size * 2.0;
451 let fields: &[Id] = match connect.step {
452 Step::Address => &[address_field()],
453 Step::SignIn if connect.guest => &[],
454 Step::SignIn => &[user_field(), password_field(), domain_field()],
455 Step::Browse => &[],
456 };
457 let entered =
458 ui::popup::navigation(ui, fields, &[NamedKey::Enter]).contains(&NamedKey::Enter);
459 ui.open_as(
460 id(),
461 Spec {
462 axis: Axis::Y,
463 size: [px(WIDTH), children()],
464 fill: Some(theme.popup),
465 border: Some(theme.chip),
466 shadow: Some(theme.shadow),
467 radius: 8.0,
468 pad: [16.0, 12.0],
469 gap: 6.0,
470 anchor: Some(Anchor::Dialog),
471 role: Some(Role::Dialog),
472 ..Spec::default()
473 },
474 );
475 let server = connect
476 .mount
477 .as_ref()
478 .map(|mount| mount.host().to_owned())
479 .unwrap_or_default();
480 let title = match connect.step {
481 Step::Address => "Connect to Server".to_owned(),
482 Step::SignIn => {
483 format!("Enter your name and password for the server \u{201c}{server}\u{201d}.")
484 }
485 Step::Browse => "Choose a Notebook".to_owned(),
486 };
487 if let Some(node) = ui.access(id()) {
488 node.set_label("Open Notebook from Server");
489 }
490 ui.leaf(
491 "title",
492 Spec {
493 size: [fill(), px(row)],
494 text: Some(&title),
495 bold: true,
496 role: Some(Role::Heading),
497 ..Spec::default()
498 },
499 );
500 let field_spec = Spec {
501 size: [fill(), px(row)],
502 fill: Some(theme.base),
503 border: Some(theme.accent),
504 radius: 4.0,
505 pad: [6.0, 0.0],
506 ..Spec::default()
507 };
508 let mut chosen = None;
509 let mut back = false;
510 match connect.step {
511 Step::Address => {
512 labelled(ui, "Server Address:", |ui| {
513 ui::text_field(
514 ui,
515 address_field(),
516 &mut connect.address,
517 "smb://server/share",
518 field_spec.clone(),
519 );
520 name(ui, address_field(), "Server Address");
521 });
522 }
523 Step::SignIn => {
524 if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked {
525 connect.guest = !connect.guest;
526 }
527 if !connect.guest {
528 labelled(ui, "Name:", |ui| {
529 ui::text_field(ui, user_field(), &mut connect.user, "", field_spec.clone());
530 name(ui, user_field(), "Name");
531 });
532 labelled(ui, "Password:", |ui| {
533 ui::password_field(
534 ui,
535 password_field(),
536 &mut connect.password,
537 "",
538 field_spec.clone(),
539 );
540 name(ui, password_field(), "Password");
541 });
542 labelled(ui, "Domain:", |ui| {
543 ui::text_field(
544 ui,
545 domain_field(),
546 &mut connect.domain,
547 "Optional",
548 field_spec.clone(),
549 );
550 name(ui, domain_field(), "Domain");
551 });
552 if let (Some(remember), Some(label)) =
553 (connect.remember, platform::remember_label())
554 && ui::check_box(ui, "remember", label, remember).clicked
555 {
556 connect.remember = Some(!remember);
557 }
558 }
559 }
560 Step::Browse => {
561 let mount = connect.mount.as_ref();
562 let place: Vec<&str> = mount
563 .map(|mount| {
564 [mount.host(), mount.share.as_str()]
565 .into_iter()
566 .chain(mount.root.split('/'))
567 .filter(|part| !part.is_empty())
568 .collect()
569 })
570 .unwrap_or_default();
571 ui.leaf(
572 "place",
573 Spec {
574 flags: Flags::CLIP,
575 size: [fill(), px(row)],
576 text: Some(&place.join(" \u{25b8} ")),
577 color: Some(theme.text_dim),
578 ..Spec::default()
579 },
580 );
581 let list = ui.open(
582 "entries",
583 Spec {
584 flags: Flags::SCROLL | Flags::CLIP,
585 axis: Axis::Y,
586 size: [fill(), px(row * ROWS + 8.0)],
587 fill: Some(theme.base),
588 border: Some(theme.chip),
589 radius: 4.0,
590 pad: [4.0, 4.0],
591 role: Some(Role::List),
592 ..Spec::default()
593 },
594 );
595 if let Some(node) = ui.access(list) {
596 node.set_label("Folders");
597 }
598 let shares = mount.is_some_and(|mount| mount.share.is_empty());
599 for (index, folder) in connect.listing.folders.iter().enumerate() {
600 let entry = ui.leaf(
601 ("folder", folder),
602 Spec {
603 flags: Flags::CLICKABLE,
604 size: [fill(), px(row)],
605 icon: Some(if shares { art::SERVER } else { art::FOLDER }),
606 text: Some(folder),
607 hover_fill: Some(theme.hover()),
608 radius: 4.0,
609 pad: [8.0, 0.0],
610 gap: 6.0,
611 role: Some(Role::ListItem),
612 ..Spec::default()
613 },
614 );
615 if entry.clicked {
616 chosen = Some(index);
617 }
618 }
619 for section in &connect.listing.sections {
620 ui.leaf(
621 ("section", section),
622 Spec {
623 size: [fill(), px(row)],
624 icon: Some(art::SECTION),
625 text: Some(section.strip_suffix(".one").unwrap_or(section)),
626 color: Some(theme.text_dim),
627 pad: [8.0, 0.0],
628 gap: 6.0,
629 role: Some(Role::ListItem),
630 ..Spec::default()
631 },
632 );
633 }
634 if connect.listing.folders.is_empty() && connect.listing.sections.is_empty() {
635 ui.leaf(
636 "empty",
637 Spec {
638 size: [fill(), px(row)],
639 text: Some(if shares {
640 "No shared folders."
641 } else {
642 "No folders here."
643 }),
644 color: Some(theme.text_dim),
645 pad: [8.0, 0.0],
646 ..Spec::default()
647 },
648 );
649 }
650 ui.close();
651 }
652 }
653 let status = match &connect.status {
654 Status::Idle if connect.step == Step::Browse && connect.opens().is_none() => {
655 Some(("Open a folder that holds a notebook.", theme.text_dim))
656 }
657 Status::Idle => None,
658 Status::Waiting(waiting) => Some((*waiting, theme.text_dim)),
659 Status::Failed(reason) => Some((reason.as_str(), theme.text)),
660 };
661 if let Some((text, color)) = status {
662 ui.leaf(
663 "status",
664 Spec {
665 size: [fill(), ui::fit()],
666 text: Some(text),
667 overflow: ui::Overflow::Wrap,
668 pad: [0.0, 4.0],
669 color: Some(color),
670 role: Some(Role::Status),
671 ..Spec::default()
672 },
673 );
674 }
675 ui.open(
676 "buttons",
677 Spec {
678 size: [fill(), children()],
679 pad: [0.0, 8.0],
680 gap: 8.0,
681 ..Spec::default()
682 },
683 );
684 if connect.step != Step::Address {
685 back = ui::button(ui, "back", "Back").clicked;
686 }
687 ui.leaf(
688 "space",
689 Spec {
690 size: [fill(), px(1.0)],
691 ..Spec::default()
692 },
693 );
694 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
695 let waiting = matches!(connect.status, Status::Waiting(_));
696 let go = match connect.step {
697 Step::Browse if connect.opens().is_none() => {
698 ui.leaf(
699 "open",
700 Spec {
701 size: [ui::fit(), px(row)],
702 text: Some("Open"),
703 color: Some(theme.text_dim),
704 fill: Some(theme.chip),
705 radius: 4.0,
706 pad: [theme.font_size * 0.75, 0.0],
707 center: true,
708 role: Some(Role::Button),
709 ..Spec::default()
710 },
711 );
712 false
713 }
714 Step::Browse => ui::button(ui, "open", "Open").clicked,
715 _ => ui::button(ui, "connect", "Connect").clicked || entered,
716 };
717 ui.close();
718 ui.close();
719 if cancel {
720 self.ui.close_popup(id());
721 self.ui.set_focus(Some(crate::page()));
722 self.server = None;
723 return;
724 }
725 let connect = self.server.as_mut().expect("the dialog is open");
726 let request = if let Some(index) = chosen {
727 connect.enter(index)
728 } else if back {
729 match connect.step {
730 Step::Browse => connect.up().or_else(|| {
731 connect.step = Step::SignIn;
732 None
733 }),
734 _ => {
735 connect.asked += 1;
736 connect.step = Step::Address;
737 connect.status = Status::Idle;
738 None
739 }
740 }
741 } else if go && !waiting {
742 match connect.step {
743 Step::Browse => {
744 if let Some((mount, login)) = connect.opens() {
745 return self.open_from_server(mount, login);
746 }
747 None
748 }
749 step => {
750 let request = connect.submit();
751 if step == Step::Address && connect.step == Step::SignIn {
752 let focus = if connect.user.is_empty() {
753 user_field()
754 } else {
755 password_field()
756 };
757 self.ui.focus_all(focus);
758 }
759 request
760 }
761 }
762 } else {
763 None
764 };
765 if let Some(request) = request {
766 self.ask(request);
767 }
768 }
769}
770
771/// A row of `label` before the field `build` adds, as the Finder's sign-in lays them out.
772fn labelled(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {
773 let row = ui.theme.font_size * 2.0;
774 ui.open(
775 label,
776 Spec {
777 size: [fill(), px(row)],
778 gap: 8.0,
779 ..Spec::default()
780 },
781 );
782 ui.leaf(
783 "label",
784 Spec {
785 size: [px(110.0), px(row)],
786 text: Some(label),
787 ..Spec::default()
788 },
789 );
790 build(ui);
791 ui.close();
792}
793
794fn name(ui: &mut Ui, field: Id, label: &str) {
795 if let Some(node) = ui.access(field) {
796 node.set_label(label);
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use std::collections::HashMap;
804
805 /// A server of folders by share-relative path, taking one account.
806 struct Fake {
807 shares: Vec<&'static str>,
808 folders: HashMap<&'static str, Vec<(&'static str, bool)>>,
809 user: &'static str,
810 password: &'static str,
811 }
812
813 impl Fake {
814 fn signs_in(&self, login: &Login) -> io::Result<()> {
815 if login.user == self.user && login.password == self.password {
816 Ok(())
817 } else {
818 Err(io::Error::other(Refusal::SignIn))
819 }
820 }
821 }
822
823 impl Reach for Fake {
824 fn shares(&self, _: &Mount, login: &Login) -> io::Result<Vec<String>> {
825 self.signs_in(login)?;
826 Ok(self.shares.iter().map(|share| share.to_string()).collect())
827 }
828
829 fn list(&self, mount: &Mount, login: &Login) -> io::Result<Vec<DirectoryEntry>> {
830 self.signs_in(login)?;
831 let path = format!("{}/{}", mount.share, mount.root);
832 let entries = (self.folders.get(path.trim_end_matches('/')))
833 .ok_or_else(|| io::Error::other(Refusal::NoFolder))?;
834 Ok(entries
835 .iter()
836 .map(|(name, folder)| DirectoryEntry {
837 name: name.to_string(),
838 size: 0,
839 modified: 0,
840 attributes: if *folder { 0x10 } else { 0x20 },
841 })
842 .collect())
843 }
844 }
845
846 fn fake() -> Fake {
847 Fake {
848 shares: vec!["public", "Notes"],
849 folders: HashMap::from([
850 (
851 "Notes",
852 vec![
853 ("Garden", true),
854 (".snapshots", true),
855 ("readme.txt", false),
856 ],
857 ),
858 (
859 "Notes/Garden",
860 vec![
861 ("Open Notebook.onetoc2", false),
862 ("Beds.one", false),
863 ("OneNote_RecycleBin", true),
864 ],
865 ),
866 ]),
867 user: "clover",
868 password: "hunter2",
869 }
870 }
871
872 /// Makes the request `ask` makes and takes its reply.
873 fn run(connect: &mut Connect, ask: impl FnOnce(&mut Connect) -> Option<Request>) -> bool {
874 let request = ask(connect).expect("a request");
875 connect.receive(request.run(&fake()))
876 }
877
878 #[test]
879 fn addresses_read_as_urls_unc_paths_or_bare_names() {
880 let mount = |text| Mount::from_address(text);
881 let garden = Mount {
882 server: "nas.local".into(),
883 share: "Notes".into(),
884 user: None,
885 domain: String::new(),
886 root: "Personal/My Garden".into(),
887 };
888 assert_eq!(
889 mount("smb://nas.local/Notes/Personal/My%20Garden/"),
890 Some(garden.clone())
891 );
892 assert_eq!(
893 mount(r"\\nas.local\Notes\Personal\My Garden"),
894 Some(garden.clone())
895 );
896 assert_eq!(
897 mount(" //nas.local/Notes/Personal/My Garden"),
898 Some(garden.clone())
899 );
900 assert_eq!(
901 mount("nas.local/Notes/Personal/My Garden"),
902 Some(garden.clone())
903 );
904 assert_eq!(
905 mount("SMB://nas.local/Notes/Personal/My Garden"),
906 Some(garden)
907 );
908 let account = mount("smb://WORK;clover:secret@10.0.0.2:1445").unwrap();
909 assert_eq!(
910 (
911 account.user.as_deref(),
912 account.domain.as_str(),
913 account.share.as_str()
914 ),
915 (Some("clover"), "WORK", "")
916 );
917 assert_eq!(
918 (account.host(), account.endpoint().as_str()),
919 ("10.0.0.2", "10.0.0.2:1445")
920 );
921 assert_eq!(mount("nas").unwrap().endpoint(), "nas:445");
922 assert_eq!(mount("smb://GUEST:@nas/public").unwrap().user, None);
923 assert_eq!(mount(""), None);
924 assert_eq!(mount("smb:///share"), None);
925 assert_eq!(mount("two words/share"), None);
926 }
927
928 #[test]
929 fn addresses_kept_read_back_without_the_password() {
930 let mount =
931 Mount::from_address("smb://WORK;clover:secret@nas:1445/Notes/My Garden").unwrap();
932 let url = mount.url();
933 assert_eq!(url, "smb://WORK;clover@nas:1445/Notes/My%20Garden");
934 assert_eq!(Mount::from_address(&url), Some(mount));
935 assert_eq!(
936 crate::library::server_address("smb://nas/Notes").map(|mount| mount.share),
937 Some("Notes".to_owned())
938 );
939 assert_eq!(crate::library::server_address("/Volumes/Notes"), None);
940 }
941
942 /// Address, sign-in, the server's shares, a share's folders, then a notebook to open.
943 #[test]
944 fn signing_in_browses_to_a_notebook() {
945 let mut connect = Connect::new("smb://nas".into(), Some(true));
946 assert!(connect.submit().is_none());
947 assert_eq!(connect.step, Step::SignIn);
948 assert!(connect.submit().is_none(), "no name and not a guest");
949 assert!(matches!(connect.status, Status::Failed(_)));
950 connect.user = "clover".into();
951 connect.password = "wrong".into();
952 assert!(!run(&mut connect, Connect::submit));
953 assert_eq!(connect.step, Step::SignIn);
954 assert_eq!(
955 connect.status,
956 Status::Failed("The name or password is incorrect.".into())
957 );
958 assert!(connect.password.is_empty());
959 connect.password = "hunter2".into();
960 assert!(run(&mut connect, Connect::submit), "signed in");
961 assert_eq!(connect.listing.folders, ["Notes", "public"]);
962 assert!(connect.opens().is_none());
963 assert!(!run(&mut connect, |connect| connect.enter(0)));
964 assert_eq!(
965 connect.listing.folders,
966 ["Garden"],
967 "hidden and plain files left out"
968 );
969 assert!(connect.opens().is_none());
970 run(&mut connect, |connect| connect.enter(0));
971 assert_eq!(connect.listing.sections, ["Beds.one"]);
972 assert!(
973 connect.listing.folders.is_empty(),
974 "the recycle bin is left out"
975 );
976 let (mount, login) = connect.opens().unwrap();
977 assert_eq!(mount.url(), "smb://clover@nas/Notes/Garden");
978 assert_eq!(
979 (login.user.as_str(), login.password.as_str()),
980 ("clover", "hunter2")
981 );
982 run(&mut connect, Connect::up);
983 run(&mut connect, Connect::up);
984 assert_eq!(connect.listing.folders, ["Notes", "public"]);
985 assert!(connect.up().is_none(), "the server's shares are the top");
986 }
987
988 #[test]
989 fn a_missing_folder_or_a_stale_reply_leaves_the_browser_as_it_was() {
990 let mut connect = Connect::new("smb://clover@nas/Notes/Gone".into(), None);
991 connect.submit();
992 assert_eq!(connect.user, "clover");
993 connect.password = "hunter2".into();
994 assert!(!run(&mut connect, Connect::submit));
995 assert_eq!(connect.step, Step::SignIn, "the address named the folder");
996 assert_eq!(
997 connect.status,
998 Status::Failed(
999 "\u{201c}Gone\u{201d} isn\u{2019}t on the share. Check the address.".into()
1000 )
1001 );
1002 connect.mount.as_mut().unwrap().root.clear();
1003 connect.password = "hunter2".into();
1004 run(&mut connect, Connect::submit);
1005 let stale = connect.enter(0).unwrap();
1006 let current = connect.up().unwrap();
1007 assert!(!connect.receive(stale.run(&fake())));
1008 assert_eq!(connect.status, Status::Waiting("Loading\u{2026}"));
1009 connect.receive(current.run(&fake()));
1010 assert_eq!(connect.listing.folders, ["Notes", "public"]);
1011 }
1012
1013 #[test]
1014 fn guests_sign_in_without_an_account() {
1015 let mut connect = Connect::new("nas/Notes".into(), None);
1016 connect.submit();
1017 connect.guest = true;
1018 connect.user = "clover".into();
1019 let request = connect.submit().unwrap();
1020 assert_eq!(
1021 (request.mount.user.as_deref(), request.login.user.as_str()),
1022 (None, "")
1023 );
1024 connect.receive(request.run(&fake()));
1025 assert_eq!(
1026 connect.status,
1027 Status::Failed(
1028 "\u{201c}nas\u{201d} doesn\u{2019}t allow guests. Sign in with a name and password."
1029 .into()
1030 )
1031 );
1032 }
1033}
crates/snowbound/src/settings.rs+9
...@@ -22,10 +22,14 @@ pub struct Settings {...@@ -22,10 +22,14 @@ pub struct Settings {
22 pub color_scheme: ColorScheme,22 pub color_scheme: ColorScheme,
23 /// Keeps pages white in the Dark color scheme.23 /// Keeps pages white in the Dark color scheme.
24 pub light_pages: bool,24 pub light_pages: bool,
25 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.
26 pub hide_spelling: bool,
25 /// Where searches look first, as "Set This Scope as Default" chose.27 /// Where searches look first, as "Set This Scope as Default" chose.
26 pub search_scope: crate::search::Scope,28 pub search_scope: crate::search::Scope,
27 /// The tag list Customize Tags edits; none keeps OneNote's.29 /// The tag list Customize Tags edits; none keeps OneNote's.
28 pub tags: Option<Vec<canvas::editor::NoteTag>>,30 pub tags: Option<Vec<canvas::editor::NoteTag>>,
31 /// Checks for updates only when Check for Updates… asks.
32 pub manual_updates: bool,
29}33}
3034
31/// What the toolbar's buttons apply from their menus' last picks.35/// What the toolbar's buttons apply from their menus' last picks.
...@@ -132,8 +136,10 @@ impl crate::State {...@@ -132,8 +136,10 @@ impl crate::State {
132 user_name: Some(self.author.clone()),136 user_name: Some(self.author.clone()),
133 color_scheme: self.color_scheme,137 color_scheme: self.color_scheme,
134 light_pages: self.light_pages,138 light_pages: self.light_pages,
139 hide_spelling: self.hide_spelling,
135 search_scope: self.search.default,140 search_scope: self.search.default,
136 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),141 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
142 manual_updates: !self.updates.automatic(),
137 };143 };
138 if let Err(error) = settings.save(path) {144 if let Err(error) = settings.save(path) {
139 eprintln!("Cannot save the settings in {}: {error}", path.display());145 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -183,13 +189,16 @@ mod tests {...@@ -183,13 +189,16 @@ mod tests {
183 user_name: Some("Snowbound Test".into()),189 user_name: Some("Snowbound Test".into()),
184 color_scheme: ColorScheme::Dark,190 color_scheme: ColorScheme::Dark,
185 light_pages: true,191 light_pages: true,
192 hide_spelling: true,
186 search_scope: crate::search::Scope::Notebook,193 search_scope: crate::search::Scope::Notebook,
187 tags: Some(vec![canvas::editor::NoteTag {194 tags: Some(vec![canvas::editor::NoteTag {
188 label: "Snow check".into(),195 label: "Snow check".into(),
189 shape: 61,196 shape: 61,
190 color: Some(0x0000_0080),197 color: Some(0x0000_0080),
191 highlight: Some(0x00ff_cc00),198 highlight: Some(0x00ff_cc00),
199 art: Some(format!("{}.png", "ab".repeat(32))),
192 }]),200 }]),
201 manual_updates: true,
193 };202 };
194 settings.save(&path).unwrap();203 settings.save(&path).unwrap();
195 assert_eq!(Settings::load(&path), settings);204 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+141-36
...@@ -7,13 +7,16 @@ use crate::{...@@ -7,13 +7,16 @@ use crate::{
7 menus::{Dragged, Target},7 menus::{Dragged, Target},
8 rename, section_color,8 rename, section_color,
9};9};
10use notebook::discover::{Folder, SectionState};10use notebook::discover::{Folder, Reason, SectionState};
11use std::{collections::HashSet, sync::Arc};11use std::{collections::HashSet, sync::Arc};
12use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};12use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};
1313
14pub const WIDTH: f32 = 220.0;14pub const WIDTH: f32 = 220.0;
15/// The square the sidebar's button stands in at the start of the section tabs.15/// The square the sidebar's button stands in at the start of the section tabs.
16const RAIL: f32 = crate::TAB_ROW;16const RAIL: f32 = crate::TAB_ROW;
17/// How far below the row's middle the button and the header's title sit: the tabs stand
18/// on the row's foot, so their labels sit 1 to 3 pixels low.
19const DROP: f32 = 2.0;
17const ROW: f32 = 24.0;20const ROW: f32 = 24.0;
18/// How far each level of the tree sits inside its parent.21/// How far each level of the tree sits inside its parent.
19const INDENT: f32 = 16.0;22const INDENT: f32 = 16.0;
...@@ -29,13 +32,15 @@ pub enum Action {...@@ -29,13 +32,15 @@ pub enum Action {
29 Fold(String),32 Fold(String),
30 NewNotebook,33 NewNotebook,
31 OpenNotebook,34 OpenNotebook,
35 /// Signs in again to the notebook opened from its server at this location.
36 SignIn(String),
32 /// Opens the Options dialog, which macOS reaches from the application menu instead.37 /// Opens the Options dialog, which macOS reaches from the application menu instead.
33 Options,38 Options,
34 /// A row's context menu, opened here.39 /// A row's context menu, opened here.
35 Menu(Target, [f32; 2]),40 Menu(Target, [f32; 2]),
36 /// Ends renaming, with the name typed or without.41 /// Ends renaming, with the name typed or without.
37 Renamed(bool),42 Renamed(bool),
38 /// Explains why a section or group can't be read: its name and the reason.43 /// Explains why a section or group can't be opened: the alert's title and message.
39 Unavailable(String, String),44 Unavailable(String, String),
40}45}
4146
...@@ -126,15 +131,32 @@ impl Entry {...@@ -126,15 +131,32 @@ impl Entry {
126 }131 }
127}132}
128133
129/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with134/// The header, whose empty space drags the window as the tab row's does.
130/// the open section marked.135pub fn header() -> Id {
131fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) {136 Id::ROOT.child("sidebar header")
137}
138
139/// The sidebar's header row, `header` tall and dragging the window when `drags`, then
140/// with `rows` the tree of `notebooks` with the open section marked.
141fn sidebar(
142 ui: &mut Ui,
143 tree: &mut Tree,
144 notebooks: &[Arc<Library>],
145 header: f32,
146 drags: bool,
147 rows: bool,
148) {
132 let (theme, folded) = (tree.theme, tree.folded);149 let (theme, folded) = (tree.theme, tree.folded);
133 ui.open(150 ui.open_as(
134 "header",151 self::header(),
135 Spec {152 Spec {
153 flags: if drags {
154 Flags::CLICKABLE
155 } else {
156 Flags::default()
157 },
136 size: [fill(), px(header)],158 size: [fill(), px(header)],
137 pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0],159 pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0 + DROP],
138 gap: 6.0,160 gap: 6.0,
139 ..Spec::default()161 ..Spec::default()
140 },162 },
...@@ -179,7 +201,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -179,7 +201,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
179 ui.id(("notebook", index)),201 ui.id(("notebook", index)),
180 Row {202 Row {
181 label: &library.name,203 label: &library.name,
182 icon: Leading::Icon(art::NOTEBOOK),204 icon: Leading::Icon(if library.in_icloud() {
205 art::ICLOUD
206 } else {
207 art::NOTEBOOK
208 }),
183 depth: 0,209 depth: 0,
184 dim: library.notebook.is_err(),210 dim: library.notebook.is_err(),
185 fold: Some(unfolded),211 fold: Some(unfolded),
...@@ -187,7 +213,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -187,7 +213,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
187 renamed: false,213 renamed: false,
188 },214 },
189 );215 );
190 if row.clicked || fold {216 let unsigned = library.notebook.is_err()
217 && crate::library::server_address(&library.location).is_some();
218 if row.clicked && unsigned {
219 tree.action = Some(Action::SignIn(library.location.clone()));
220 } else if row.clicked || fold {
191 tree.action = Some(Action::Fold(key));221 tree.action = Some(Action::Fold(key));
192 }222 }
193 if let Some(point) = row.context {223 if let Some(point) = row.context {
...@@ -378,7 +408,16 @@ fn folder(...@@ -378,7 +408,16 @@ fn folder(
378 },408 },
379 );409 );
380 if row.clicked {410 if row.clicked {
381 tree.action = Some(Action::Unavailable(name, entry.error.clone()));411 tree.action = Some(match entry.reason {
412 Reason::Denied => Action::Unavailable(
413 format!("Can't read \u{201c}{name}\u{201d}"),
414 format!("{}\n\nIt appears here once you have access.", entry.error),
415 ),
416 _ => Action::Unavailable(
417 format!("Can't open \u{201c}{name}\u{201d}"),
418 format!("{}.", entry.error),
419 ),
420 });
382 }421 }
383 }422 }
384}423}
...@@ -550,9 +589,18 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {...@@ -550,9 +589,18 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
550 pad: [6.0 + INDENT * row.depth as f32, 0.0],589 pad: [6.0 + INDENT * row.depth as f32, 0.0],
551 // The field's text stands where the label did.590 // The field's text stands where the label did.
552 gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 },591 gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 },
592 role: Some(accesskit::Role::TreeItem),
553 ..Spec::default()593 ..Spec::default()
554 },594 },
555 );595 );
596 if let Some(node) = ui.access(id) {
597 node.set_label(row.label);
598 node.set_level(row.depth as usize + 1);
599 node.set_selected(row.selected.is_some());
600 if let Some(unfolded) = row.fold {
601 node.set_expanded(unfolded);
602 }
603 }
556 let color = if row.dim { theme.text_dim } else { theme.text };604 let color = if row.dim { theme.text_dim } else { theme.text };
557 let (icon, [red, green, blue, _]) = match row.icon {605 let (icon, [red, green, blue, _]) = match row.icon {
558 Leading::Icon(icon) => (icon, theme.text),606 Leading::Icon(icon) => (icon, theme.text),
...@@ -599,11 +647,17 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {...@@ -599,11 +647,17 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
599 ui::shell::CHEVRON647 ui::shell::CHEVRON
600 }),648 }),
601 color: Some(theme.text_dim),649 color: Some(theme.text_dim),
650 role: Some(accesskit::Role::Button),
602 ..Spec::default()651 ..Spec::default()
603 },652 },
604 )653 )
605 .clicked654 .clicked
606 });655 });
656 if let Some(unfolded) = row.fold
657 && let Some(node) = ui.access(id.child("fold"))
658 {
659 node.set_label(if unfolded { "Collapse" } else { "Expand" });
660 }
607 ui.close();661 ui.close();
608 (ui.signal(id), folded)662 (ui.signal(id), folded)
609}663}
...@@ -638,9 +692,11 @@ impl crate::State {...@@ -638,9 +692,11 @@ impl crate::State {
638 Spec {692 Spec {
639 axis: Axis::Y,693 axis: Axis::Y,
640 size: [px(WIDTH), fill()],694 size: [px(WIDTH), fill()],
695 role: Some(accesskit::Role::Tree),
641 ..Spec::default()696 ..Spec::default()
642 },697 },
643 );698 );
699 crate::name(&mut self.ui, rows_id, "Notebooks");
644 let open = self.session.as_ref().and_then(|session| {700 let open = self.session.as_ref().and_then(|session| {
645 let index = self701 let index = self
646 .notebooks702 .notebooks
...@@ -648,6 +704,7 @@ impl crate::State {...@@ -648,6 +704,7 @@ impl crate::State {
648 .position(|library| Arc::ptr_eq(library, &session.library))?;704 .position(|library| Arc::ptr_eq(library, &session.library))?;
649 Some((index, session.tabs[session.tab].path.as_str()))705 Some((index, session.tabs[session.tab].path.as_str()))
650 });706 });
707 let drags = self.chrome_drags();
651 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());708 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());
652 tree.lifted = self709 tree.lifted = self
653 .drag710 .drag
...@@ -662,6 +719,7 @@ impl crate::State {...@@ -662,6 +719,7 @@ impl crate::State {
662 &mut tree,719 &mut tree,
663 &self.notebooks,720 &self.notebooks,
664 crate::TAB_ROW,721 crate::TAB_ROW,
722 drags,
665 width > 0.5,723 width > 0.5,
666 );724 );
667 let Tree {725 let Tree {
...@@ -697,11 +755,11 @@ impl crate::State {...@@ -697,11 +755,11 @@ impl crate::State {
697 Some(Action::Renamed(keep)) => self.finish_renaming(keep),755 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
698 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),756 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
699 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),757 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
758 Some(Action::SignIn(location)) => self
759 .commands
760 .push(crate::Command::OpenFromServer(Some(location))),
700 Some(Action::Options) => self.open_options(),761 Some(Action::Options) => self.open_options(),
701 Some(Action::Unavailable(name, error)) => crate::platform::alert(762 Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message),
702 &format!("Can't read \u{201c}{name}\u{201d}"),
703 &format!("{error}\n\nIt appears here once you have access."),
704 ),
705 Some(Action::Open { .. }) | None => {}763 Some(Action::Open { .. }) | None => {}
706 }764 }
707 width765 width
...@@ -721,12 +779,14 @@ impl crate::State {...@@ -721,12 +779,14 @@ impl crate::State {
721 Spec {779 Spec {
722 flags: Flags::FLOAT | Flags::CLIP,780 flags: Flags::FLOAT | Flags::CLIP,
723 size: [px(RAIL), px(height)],781 size: [px(RAIL), px(height)],
724 pad: [(RAIL - ui::shell::TOOL) / 2.0; 2],782 pad: [
783 (RAIL - ui::shell::TOOL) / 2.0,
784 (RAIL - ui::shell::TOOL) / 2.0 + DROP,
785 ],
725 ..Spec::default()786 ..Spec::default()
726 },787 },
727 );788 );
728 if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, false).clicked789 if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, None).clicked {
729 {
730 self.sidebar = !self.sidebar;790 self.sidebar = !self.sidebar;
731 self.save_settings();791 self.save_settings();
732 }792 }
...@@ -955,10 +1015,23 @@ impl crate::State {...@@ -955,10 +1015,23 @@ impl crate::State {
955 /// Shows the notebook at `location` at `section`, or its first section, opening it1015 /// Shows the notebook at `location` at `section`, or its first section, opening it
956 /// unless it is open.1016 /// unless it is open.
957 pub(crate) fn open_notebook(&mut self, location: String, section: Option<String>) {1017 pub(crate) fn open_notebook(&mut self, location: String, section: Option<String>) {
1018 self.open_notebook_with(location, section, |location, cache| {
1019 Ok(Library::notebook(location, cache))
1020 });
1021 }
1022
1023 /// Shows the notebook at `location` as `open_notebook` does, reading it with `read` unless
1024 /// it is open and readable.
1025 pub(crate) fn open_notebook_with(
1026 &mut self,
1027 location: String,
1028 section: Option<String>,
1029 read: impl FnOnce(&str, &std::path::Path) -> Result<Library, String> + Send + 'static,
1030 ) {
958 let open = self1031 let open = self
959 .notebooks1032 .notebooks
960 .iter()1033 .iter()
961 .find(|library| library.location == location)1034 .find(|library| library.location == location && library.notebook.is_ok())
962 .cloned();1035 .cloned();
963 // The notebook shown already shows a section, which only one reader may hold.1036 // The notebook shown already shows a section, which only one reader may hold.
964 if let Some(session) = &self.session1037 if let Some(session) = &self.session
...@@ -971,7 +1044,10 @@ impl crate::State {...@@ -971,7 +1044,10 @@ impl crate::State {
971 }1044 }
972 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));1045 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
973 self.load(move || {1046 self.load(move || {
974 let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache)));1047 let library = match open {
1048 Some(library) => library,
1049 None => Arc::new(read(&location, &cache)?),
1050 };
975 if let Err(error) = &library.notebook {1051 if let Err(error) = &library.notebook {
976 return Err(error.clone().into());1052 return Err(error.clone().into());
977 }1053 }
...@@ -989,25 +1065,52 @@ impl crate::State {...@@ -989,25 +1065,52 @@ impl crate::State {
989 /// what is missing, and the two ways to start.1065 /// what is missing, and the two ways to start.
990 pub(crate) fn welcome(&mut self, theme: &Theme) {1066 pub(crate) fn welcome(&mut self, theme: &Theme) {
991 let id = self.ui.id("welcome");1067 let id = self.ui.id("welcome");
1068 #[cfg(target_os = "linux")]
1069 let install = crate::desktop::installable().then_some((
1070 "install",
1071 art::PLUS,
1072 "Install Snowbound",
1073 crate::Command::Install,
1074 ));
1075 #[cfg(not(target_os = "linux"))]
1076 let install = None;
992 self.notice(1077 self.notice(
993 theme,1078 theme,
994 id,1079 id,
995 theme.base,1080 theme.base,
996 "No notebooks open",1081 "No notebooks open",
997 vec![1082 crate::icloud::folder()
998 (1083 .map(|_| {
999 "new",1084 (
1000 art::PLUS,1085 "icloud",
1001 "New Notebook",1086 art::ICLOUD,
1002 crate::Command::NewNotebook,1087 "Use iCloud Drive",
1003 ),1088 crate::Command::UseICloud,
1004 (1089 )
1005 "open",1090 })
1006 art::NOTEBOOK,1091 .into_iter()
1007 "Open Existing",1092 .chain([
1008 crate::Command::OpenNotebook,1093 (
1009 ),1094 "new",
1010 ],1095 art::PLUS,
1096 "New Notebook",
1097 crate::Command::NewNotebook,
1098 ),
1099 (
1100 "open",
1101 art::NOTEBOOK,
1102 "Open Existing",
1103 crate::Command::OpenNotebook,
1104 ),
1105 (
1106 "server",
1107 art::SERVER,
1108 "Open Notebook from Server…",
1109 crate::Command::OpenFromServer(None),
1110 ),
1111 ])
1112 .chain(install)
1113 .collect(),
1011 );1114 );
1012 }1115 }
10131116
...@@ -1020,9 +1123,10 @@ impl crate::State {...@@ -1020,9 +1123,10 @@ impl crate::State {
1020 folder: String::new(),1123 folder: String::new(),
1021 },1124 },
1022 );1125 );
1126 let id = self.ui.id("no sections");
1023 self.notice(1127 self.notice(
1024 theme,1128 theme,
1025 crate::page(),1129 id,
1026 theme.strip,1130 theme.strip,
1027 "No sections in this notebook",1131 "No sections in this notebook",
1028 vec![("new", art::PLUS, "New Section", new)],1132 vec![("new", art::PLUS, "New Section", new)],
...@@ -1039,7 +1143,7 @@ impl crate::State {...@@ -1039,7 +1143,7 @@ impl crate::State {
1039 title: &str,1143 title: &str,
1040 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,1144 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,
1041 ) {1145 ) {
1042 const BUTTON: [f32; 2] = [200.0, 32.0];1146 const BUTTON: [f32; 2] = [240.0, 32.0];
1043 let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default();1147 let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default();
1044 self.ui.open_as(1148 self.ui.open_as(
1045 id,1149 id,
...@@ -1086,6 +1190,7 @@ impl crate::State {...@@ -1086,6 +1190,7 @@ impl crate::State {
1086 hover_border: Some(theme.accent),1190 hover_border: Some(theme.accent),
1087 radius: 6.0,1191 radius: 6.0,
1088 center: true,1192 center: true,
1193 role: Some(accesskit::Role::Button),
1089 ..Spec::default()1194 ..Spec::default()
1090 },1195 },
1091 );1196 );
crates/snowbound/src/spell_linux.rs created+176
...@@ -0,0 +1,176 @@
1//! Spelling through Enchant, loaded at run time with whichever dictionaries its providers
2//! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked.
3
4use canvas::spelling::{Dictionary, pick};
5use std::collections::HashMap;
6use std::ffi::{CStr, CString, c_char, c_int, c_void};
7use std::sync::Mutex;
8
9type Broker = *mut c_void;
10type Dict = *mut c_void;
11type Describe =
12 unsafe extern "C" fn(*const c_char, *const c_char, *const c_char, *const c_char, *mut c_void);
13
14struct Api {
15 request: unsafe extern "C" fn(Broker, *const c_char) -> Dict,
16 list: unsafe extern "C" fn(Broker, Describe, *mut c_void),
17 check: unsafe extern "C" fn(Dict, *const c_char, isize) -> c_int,
18 suggest: unsafe extern "C" fn(Dict, *const c_char, isize, *mut usize) -> *mut *mut c_char,
19 free: unsafe extern "C" fn(Dict, *mut *mut c_char),
20 add: unsafe extern "C" fn(Dict, *const c_char, isize),
21}
22
23struct Enchant {
24 api: Api,
25 broker: Broker,
26 /// The dictionaries Enchant has, as `en_US`.
27 languages: Vec<String>,
28 /// Dictionaries opened so far, by LCID; none where no dictionary serves it.
29 open: HashMap<u32, Option<Dict>>,
30}
31
32// SAFETY: Enchant's broker and dictionaries are used by one thread at a time, under the
33// checker's lock.
34unsafe impl Send for Enchant {}
35
36impl Enchant {
37 fn dict(&mut self, lcid: u32) -> Option<Dict> {
38 if let Some(dict) = self.open.get(&lcid) {
39 return *dict;
40 }
41 let dict = pick(lcid, &self.languages)
42 .and_then(|name| CString::new(name).ok())
43 .map(|name| unsafe { (self.api.request)(self.broker, name.as_ptr()) })
44 .filter(|dict| !dict.is_null());
45 self.open.insert(lcid, dict);
46 dict
47 }
48}
49
50struct Checker(Mutex<Enchant>);
51
52impl Dictionary for Checker {
53 fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> {
54 let mut enchant = self.0.lock().unwrap();
55 words
56 .iter()
57 .map(|(word, lcid)| {
58 enchant.dict(*lcid).is_some_and(|dict| {
59 let word = CString::new(*word).unwrap_or_default();
60 let length = word.as_bytes().len() as isize;
61 unsafe { (enchant.api.check)(dict, word.as_ptr(), length) > 0 }
62 })
63 })
64 .collect()
65 }
66
67 fn suggest(&self, word: &str, lcid: u32) -> Vec<String> {
68 let mut enchant = self.0.lock().unwrap();
69 let (Some(dict), Ok(word)) = (enchant.dict(lcid), CString::new(word)) else {
70 return Vec::new();
71 };
72 let mut count = 0;
73 let length = word.as_bytes().len() as isize;
74 unsafe {
75 let list = (enchant.api.suggest)(dict, word.as_ptr(), length, &mut count);
76 if list.is_null() {
77 return Vec::new();
78 }
79 let suggestions = std::slice::from_raw_parts(list, count)
80 .iter()
81 .map(|suggestion| CStr::from_ptr(*suggestion).to_string_lossy().into_owned())
82 .collect();
83 (enchant.api.free)(dict, list);
84 suggestions
85 }
86 }
87
88 /// Adds `word` to the personal word list of every dictionary opened.
89 fn learn(&self, word: &str) {
90 let enchant = self.0.lock().unwrap();
91 let Ok(word) = CString::new(word) else {
92 return;
93 };
94 let length = word.as_bytes().len() as isize;
95 for dict in enchant.open.values().flatten() {
96 unsafe { (enchant.api.add)(*dict, word.as_ptr(), length) };
97 }
98 }
99}
100
101/// The function `name` in `library`, of C signature `F`.
102unsafe fn symbol<F: Copy>(library: *mut c_void, name: &CStr) -> Option<F> {
103 let symbol = unsafe { libc::dlsym(library, name.as_ptr()) };
104 (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) })
105}
106
107unsafe extern "C" fn describe(
108 tag: *const c_char,
109 _: *const c_char,
110 _: *const c_char,
111 _: *const c_char,
112 languages: *mut c_void,
113) {
114 let languages = unsafe { &mut *languages.cast::<Vec<String>>() };
115 let tag = unsafe { CStr::from_ptr(tag) }
116 .to_string_lossy()
117 .into_owned();
118 if !languages.contains(&tag) {
119 languages.push(tag);
120 }
121}
122
123/// Enchant 2 or 1 with its dictionaries; none where it is not installed.
124pub fn dictionary() -> Option<Box<dyn Dictionary>> {
125 unsafe {
126 let library = [c"libenchant-2.so.2", c"libenchant.so.1"]
127 .into_iter()
128 .map(|name| libc::dlopen(name.as_ptr(), libc::RTLD_NOW))
129 .find(|library| !library.is_null())?;
130 let init: unsafe extern "C" fn() -> Broker = symbol(library, c"enchant_broker_init")?;
131 let api = Api {
132 request: symbol(library, c"enchant_broker_request_dict")?,
133 list: symbol(library, c"enchant_broker_list_dicts")?,
134 check: symbol(library, c"enchant_dict_check")?,
135 suggest: symbol(library, c"enchant_dict_suggest")?,
136 free: symbol(library, c"enchant_dict_free_string_list")?,
137 add: symbol(library, c"enchant_dict_add")
138 .or_else(|| symbol(library, c"enchant_dict_add_to_personal"))?,
139 };
140 let broker = init();
141 if broker.is_null() {
142 return None;
143 }
144 let mut languages: Vec<String> = Vec::new();
145 (api.list)(broker, describe, (&raw mut languages).cast());
146 Some(Box::new(Checker(Mutex::new(Enchant {
147 api,
148 broker,
149 languages,
150 open: HashMap::new(),
151 }))))
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 /// Where Enchant has an English dictionary, it marks misspellings and corrects them; where
158 /// it has none, words stay unmarked.
159 #[test]
160 fn enchant_checks_the_languages_it_has() {
161 let Some(dictionary) = super::dictionary() else {
162 return;
163 };
164 let marked = dictionary.misspelled(&[("sentence", 1033), ("sentense", 1033)]);
165 if marked == [false, false] {
166 return;
167 }
168 assert_eq!(marked, [false, true]);
169 assert!(
170 dictionary
171 .suggest("sentense", 1033)
172 .contains(&"sentence".to_owned())
173 );
174 assert_eq!(dictionary.misspelled(&[("sentense", 0x1007f)]), [false]);
175 }
176}
crates/snowbound/src/spell_macos.rs created+117
...@@ -0,0 +1,117 @@
1//! Spelling through AppKit's shared NSSpellChecker, which the spelling thread reaches on
2//! the main thread, as AppKit's objects expect.
3
4use canvas::spelling::{Dictionary, pick};
5use objc2::{class, msg_send, msg_send_id, rc::Retained, runtime::AnyObject};
6use objc2_foundation::{MainThreadMarker, NSArray, NSRange, NSString};
7use std::ffi::c_void;
8
9unsafe extern "C" {
10 /// The main queue; only its address matters.
11 static _dispatch_main_q: u8;
12 fn dispatch_sync_f(queue: *mut c_void, context: *mut c_void, work: extern "C" fn(*mut c_void));
13}
14
15/// Runs `work` on the main thread, waiting for it.
16fn on_main<F: FnOnce() -> R + Send, R: Send>(work: F) -> R {
17 if MainThreadMarker::new().is_some() {
18 return work();
19 }
20 extern "C" fn run<F: FnOnce() -> R, R>(context: *mut c_void) {
21 let (work, result) = unsafe { &mut *context.cast::<(Option<F>, Option<R>)>() };
22 *result = work.take().map(|work| work());
23 }
24 let mut slot = (Some(work), None);
25 unsafe {
26 dispatch_sync_f(
27 (&raw const _dispatch_main_q).cast_mut().cast(),
28 (&raw mut slot).cast(),
29 run::<F, R>,
30 );
31 }
32 slot.1.expect("the main queue ran the work")
33}
34
35fn checker() -> Retained<AnyObject> {
36 unsafe { msg_send_id![class!(NSSpellChecker), sharedSpellChecker] }
37}
38
39struct Checker {
40 /// The document tag Snowbound's checks share.
41 tag: isize,
42 /// The spelling languages the system has, as `en` or `en_GB`.
43 languages: Vec<String>,
44}
45
46impl Checker {
47 fn language(&self, lcid: u32) -> Option<Retained<NSString>> {
48 pick(lcid, &self.languages).map(NSString::from_str)
49 }
50}
51
52impl Dictionary for Checker {
53 fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> {
54 on_main(|| {
55 let checker = checker();
56 words
57 .iter()
58 .map(|(word, lcid)| {
59 let Some(language) = self.language(*lcid) else {
60 return false;
61 };
62 let found: NSRange = unsafe {
63 msg_send![&checker,
64 checkSpellingOfString: &*NSString::from_str(word),
65 startingAt: 0isize,
66 language: &*language,
67 wrap: false,
68 inSpellDocumentWithTag: self.tag,
69 wordCount: std::ptr::null_mut::<isize>()]
70 };
71 found.length > 0
72 })
73 .collect()
74 })
75 }
76
77 fn suggest(&self, word: &str, lcid: u32) -> Vec<String> {
78 on_main(|| {
79 let Some(language) = self.language(lcid) else {
80 return Vec::new();
81 };
82 let word = NSString::from_str(word);
83 let guesses: Option<Retained<NSArray<NSString>>> = unsafe {
84 msg_send_id![&checker(),
85 guessesForWordRange: NSRange::new(0, word.length()),
86 inString: &*word,
87 language: &*language,
88 inSpellDocumentWithTag: self.tag]
89 };
90 guesses.map_or_else(Vec::new, |guesses| {
91 guesses.iter().map(|guess| guess.to_string()).collect()
92 })
93 })
94 }
95
96 fn learn(&self, word: &str) {
97 on_main(|| unsafe {
98 let _: () = msg_send![&checker(), learnWord: &*NSString::from_str(word)];
99 });
100 }
101}
102
103/// AppKit's spell checker with the languages the system has. Called on the main thread.
104pub fn dictionary() -> Option<Box<dyn Dictionary>> {
105 MainThreadMarker::new()?;
106 let checker = checker();
107 let languages: Retained<NSArray<NSString>> =
108 unsafe { msg_send_id![&checker, availableLanguages] };
109 let tag: isize = unsafe { msg_send![class!(NSSpellChecker), uniqueSpellDocumentTag] };
110 Some(Box::new(Checker {
111 tag,
112 languages: languages
113 .iter()
114 .map(|language| language.to_string())
115 .collect(),
116 }))
117}
crates/snowbound/src/sync.rs+99-5
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook
2//! Synchronization for the open section's notebook and each of its sections.2//! Synchronization for the open section's notebook and each of its sections.
33
4use crate::{Session, State, art, filetime, library, platform};4use crate::{Session, State, art, filetime, library, platform, update};
5use notebook::session::{SyncState, SyncStatus};5use notebook::session::{SyncState, SyncStatus};
6use std::error::Error;6use std::error::Error;
7use ui::{7use ui::{
...@@ -45,6 +45,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option...@@ -45,6 +45,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option
45 art::SYNC_OFFLINE,45 art::SYNC_OFFLINE,
46 Some("Changes stay on this computer and sync when the notebook is back."),46 Some("Changes stay on this computer and sync when the notebook is back."),
47 ),47 ),
48 SyncState::Protected => (
49 "Password protected",
50 art::SYNC_ERROR,
51 Some("Snowbound can’t open password-protected sections yet."),
52 ),
53 SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None),
48 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),54 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),
49 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),55 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),
50 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),56 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),
...@@ -115,8 +121,21 @@ fn when(time: u64) -> String {...@@ -115,8 +121,21 @@ fn when(time: u64) -> String {
115 }121 }
116}122}
117123
118/// The status's icon in the toolbar, named in its tooltip, which opens the popup.124const TITLE: &str = "Notebook Sync Status";
119pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) {125
126/// What the popup says of a newer build: downloading, or waiting to be installed.
127fn update_note(update: &update::Status) -> Option<String> {
128 match update {
129 update::Status::Downloading(version) => Some(format!("Downloading {version}…")),
130 update::Status::Ready(version, _) => Some(format!("{version} is ready")),
131 update::Status::Available(version) => Some(format!("{version} is available")),
132 _ => None,
133 }
134}
135
136/// The status's icon in the toolbar, which opens the popup; its tooltip names the status,
137/// and a dot on it says a newer build is ready.
138pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {
120 let sync = overall(&sections(session));139 let sync = overall(&sections(session));
121 let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline();140 let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline();
122 let (label, icon, _) = describe(&sync);141 let (label, icon, _) = describe(&sync);
...@@ -130,11 +149,40 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) {...@@ -130,11 +149,40 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) {
130 hover_fill: Some(theme.hover()),149 hover_fill: Some(theme.hover()),
131 radius: 4.0,150 radius: 4.0,
132 center: true,151 center: true,
152 role: Some(accesskit::Role::Button),
133 ..Spec::default()153 ..Spec::default()
134 },154 },
135 );155 );
156 let waiting = matches!(
157 update,
158 update::Status::Ready(..) | update::Status::Available(_)
159 );
160 if waiting {
161 ui.leaf(
162 "update",
163 Spec {
164 flags: Flags::FLOAT,
165 size: [px(7.0), px(7.0)],
166 position: [TOOL - 8.0, 1.0],
167 fill: Some(theme.accent),
168 radius: 3.5,
169 ..Spec::default()
170 },
171 );
172 }
173 let open = ui.popup_open(id());
174 let note = update_note(update).filter(|_| waiting);
175 if let Some(node) = ui.access(button()) {
176 node.set_label(TITLE);
177 node.set_value(label);
178 if let Some(note) = &note {
179 node.set_description(format!("Snowbound {note}"));
180 }
181 node.set_has_popup(accesskit::HasPopup::Dialog);
182 node.set_expanded(open);
183 }
136 ui.close();184 ui.close();
137 ui::popup::tooltip(ui, label, "", None);185 ui::popup::tooltip(ui, label, "", note.as_deref());
138 if ui.signal(button()).clicked {186 if ui.signal(button()).clicked {
139 if ui.popup_open(id()) {187 if ui.popup_open(id()) {
140 ui.close_popup(id());188 ui.close_popup(id());
...@@ -148,6 +196,7 @@ impl State {...@@ -148,6 +196,7 @@ impl State {
148 /// Builds the sync status popup while it is open.196 /// Builds the sync status popup while it is open.
149 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {197 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {
150 let (ui, notebooks) = (&mut self.ui, &self.notebooks);198 let (ui, notebooks) = (&mut self.ui, &self.notebooks);
199 let update = self.updates.status();
151 let Some(session) = &mut self.session else {200 let Some(session) = &mut self.session else {
152 ui.close_popup(id());201 ui.close_popup(id());
153 return Ok(());202 return Ok(());
...@@ -174,9 +223,13 @@ impl State {...@@ -174,9 +223,13 @@ impl State {
174 pad: [14.0, 10.0],223 pad: [14.0, 10.0],
175 gap: 2.0,224 gap: 2.0,
176 anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())),225 anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())),
226 role: Some(accesskit::Role::Dialog),
177 ..Spec::default()227 ..Spec::default()
178 },228 },
179 );229 );
230 if let Some(node) = ui.access(id()) {
231 node.set_label(TITLE);
232 }
180 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| {233 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| {
181 ui.leaf(234 ui.leaf(
182 part,235 part,
...@@ -220,7 +273,7 @@ impl State {...@@ -220,7 +273,7 @@ impl State {
220 );273 );
221 ui.close();274 ui.close();
222 };275 };
223 text(ui, "title", "Notebook Sync Status", theme.text, true);276 text(ui, "title", TITLE, theme.text, true);
224 row(ui, "Notebook", &session.library.name);277 row(ui, "Notebook", &session.library.name);
225 row(ui, "Location", &session.library.location);278 row(ui, "Location", &session.library.location);
226 row(ui, "Connection", &session.library.transport());279 row(ui, "Connection", &session.library.transport());
...@@ -277,6 +330,32 @@ impl State {...@@ -277,6 +330,32 @@ impl State {
277 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");330 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");
278 text(ui, "notice", &notice, theme.text_dim, false);331 text(ui, "notice", &notice, theme.text_dim, false);
279 }332 }
333 let (mut folder, mut restart) = (false, false);
334 if let Some(note) = update_note(&update) {
335 text(ui, "update-title", "Snowbound Update", theme.text, true);
336 row(ui, "Update", &note);
337 ui.open(
338 "update",
339 Spec {
340 size: [fill(), children()],
341 pad: [0.0, 4.0],
342 gap: 8.0,
343 ..Spec::default()
344 },
345 );
346 folder = ui::button(ui, "build-folder", "Build Folder").clicked;
347 ui.leaf(
348 "space",
349 Spec {
350 size: [fill(), px(1.0)],
351 ..Spec::default()
352 },
353 );
354 if matches!(update, update::Status::Ready(..)) {
355 restart = ui::button(ui, "restart", "Restart to Update").clicked;
356 }
357 ui.close();
358 }
280 ui.open(359 ui.open(
281 "controls",360 "controls",
282 Spec {361 Spec {
...@@ -308,9 +387,13 @@ impl State {...@@ -308,9 +387,13 @@ impl State {
308 radius: 4.0,387 radius: 4.0,
309 pad: [theme.font_size * 0.75, 0.0],388 pad: [theme.font_size * 0.75, 0.0],
310 center: true,389 center: true,
390 role: Some(accesskit::Role::Button),
311 ..Spec::default()391 ..Spec::default()
312 },392 },
313 );393 );
394 if let Some(node) = ui.access(ui.id("show-file")) {
395 node.set_disabled();
396 }
314 false397 false
315 }398 }
316 };399 };
...@@ -331,6 +414,17 @@ impl State {...@@ -331,6 +414,17 @@ impl State {
331 if let Some(file) = file.filter(|_| show) {414 if let Some(file) = file.filter(|_| show) {
332 platform::show_file(&file);415 platform::show_file(&file);
333 }416 }
417 match update {
418 update::Status::Downloading(version)
419 | update::Status::Ready(version, _)
420 | update::Status::Available(version)
421 if folder =>
422 {
423 update::show_build(&version)
424 }
425 _ if restart => self.restart_to_update(),
426 _ => {}
427 }
334 Ok(())428 Ok(())
335 }429 }
336}430}
crates/snowbound/src/tags.rs+601-35
...@@ -2,10 +2,12 @@...@@ -2,10 +2,12 @@
2//! the user's tag list: the tags the toolbar, the menus and Ctrl+1 to Ctrl+9 apply.2//! the user's tag list: the tags the toolbar, the menus and Ctrl+1 to Ctrl+9 apply.
33
4use crate::{State, commands, platform};4use crate::{State, commands, platform};
5use accesskit::Role;
5use canvas::editor::NoteTag;6use canvas::editor::NoteTag;
6use canvas::gpu::{colorref, tag_sources};7use canvas::gpu::{colorref, tag_sources};
7use canvas::outline::{TagIcon, symbol_name};8use canvas::outline::{TagIcon, symbol_name};
8use draw::edit::Platform;9use draw::edit::Platform;
10use notebook::sidecar::art_name;
9use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};11use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};
10use winit::keyboard::NamedKey;12use winit::keyboard::NamedKey;
1113
...@@ -42,21 +44,132 @@ const GALLERY: [[u16; 15]; 10] = [...@@ -42,21 +44,132 @@ const GALLERY: [[u16; 15]; 10] = [
42 ],44 ],
43];45];
4446
45/// Font Color's swatches, Office's forty in their grid, as COLORREFs.47/// Snowbound's own symbols past OneNote's: their names, the symbols OneNote shows in their
46const FONT_COLORS: [u32; 40] = [48/// place, and their art, which a notebook keeps as it keeps a picture chosen for a tag.
47 0x000000, 0x003399, 0x003333, 0x003300, 0x663300, 0x800000, 0x993333, 0x333333, //49const EXTRAS: [(&str, u16, &str); 15] = [
48 0x000080, 0x0066ff, 0x008080, 0x008000, 0x808000, 0xff0000, 0x996666, 0x808080, //50 (
49 0x0000ff, 0x0099ff, 0x00cc99, 0x669933, 0xcccc33, 0xff6633, 0x800080, 0x969696, //51 "Snowflake",
50 0xff00ff, 0x00ccff, 0x00ffff, 0x00ff00, 0xffff00, 0xffcc00, 0x663399, 0xc0c0c0, //52 34,
51 0xcc99ff, 0x99ccff, 0x99ffff, 0xccffcc, 0xffffcc, 0xffcc99, 0xff99cc, 0xffffff,53 include_str!("../assets/tags/snowflake.svg"),
54 ),
55 ("Rocket", 127, include_str!("../assets/tags/rocket.svg")),
56 ("Fire", 140, include_str!("../assets/tags/fire.svg")),
57 (
58 "Thumbs Up",
59 55,
60 include_str!("../assets/tags/thumbs-up.svg"),
61 ),
62 (
63 "Thumbs Down",
64 113,
65 include_str!("../assets/tags/thumbs-down.svg"),
66 ),
67 ("Laughing", 25, include_str!("../assets/tags/laughing.svg")),
68 (
69 "Surprised",
70 25,
71 include_str!("../assets/tags/surprised.svg"),
72 ),
73 ("Bug", 17, include_str!("../assets/tags/bug.svg")),
74 ("Trophy", 26, include_str!("../assets/tags/trophy.svg")),
75 ("Gift", 142, include_str!("../assets/tags/gift.svg")),
76 ("Coffee", 20, include_str!("../assets/tags/coffee.svg")),
77 ("Warning", 17, include_str!("../assets/tags/warning.svg")),
78 ("Map Pin", 22, include_str!("../assets/tags/map-pin.svg")),
79 ("Camera", 122, include_str!("../assets/tags/camera.svg")),
80 ("Sparkles", 13, include_str!("../assets/tags/sparkles.svg")),
52];81];
5382
54/// Highlight Color's swatches as COLORREFs, which differ from the highlighter's in Sky Blue83/// The symbol OneNote shows for a tag drawn with a picture chosen for it, until another is
55/// and Dark Green.84/// picked.
56const HIGHLIGHTS: [u32; 15] = [85const PICTURE_FALLBACK: u16 = 36;
57 0x00ffff, 0x00ff00, 0xffcc00, 0xff00ff, 0xff0000, //86
58 0x0000ff, 0x800000, 0x808000, 0x003300, 0x800080, //87/// The names a notebook keeps `EXTRAS`' art under.
59 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000,88fn extra_art() -> &'static [String] {
89 static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
90 NAMES.get_or_init(|| {
91 EXTRAS
92 .iter()
93 .map(|(_, _, svg)| art_name(svg.as_bytes(), "svg"))
94 .collect()
95 })
96}
97
98/// Where the pictures chosen for tags are kept, by the names a notebook keeps them under.
99fn pictures() -> Option<std::path::PathBuf> {
100 Some(platform::settings_dir()?.join("tag-art"))
101}
102
103/// Tag art `art`: one of Snowbound's own symbols, or a picture chosen for a tag.
104pub(crate) fn art_bytes(art: &str) -> Option<Vec<u8>> {
105 match extra_art().iter().position(|name| name == art) {
106 Some(extra) => Some(EXTRAS[extra].2.as_bytes().to_vec()),
107 None => std::fs::read(pictures()?.join(art)).ok(),
108 }
109}
110
111/// Font Color's swatches, Office's forty in their grid, as COLORREFs with their names.
112const FONT_COLORS: [(u32, &str); 40] = [
113 (0x000000, "Black"),
114 (0x003399, "Brown"),
115 (0x003333, "Olive Green"),
116 (0x003300, "Dark Green"),
117 (0x663300, "Dark Teal"),
118 (0x800000, "Dark Blue"),
119 (0x993333, "Indigo"),
120 (0x333333, "Gray-80%"),
121 (0x000080, "Dark Red"),
122 (0x0066ff, "Orange"),
123 (0x008080, "Dark Yellow"),
124 (0x008000, "Green"),
125 (0x808000, "Teal"),
126 (0xff0000, "Blue"),
127 (0x996666, "Blue-Gray"),
128 (0x808080, "Gray-50%"),
129 (0x0000ff, "Red"),
130 (0x0099ff, "Light Orange"),
131 (0x00cc99, "Lime"),
132 (0x669933, "Sea Green"),
133 (0xcccc33, "Aqua"),
134 (0xff6633, "Light Blue"),
135 (0x800080, "Violet"),
136 (0x969696, "Gray-40%"),
137 (0xff00ff, "Pink"),
138 (0x00ccff, "Gold"),
139 (0x00ffff, "Yellow"),
140 (0x00ff00, "Bright Green"),
141 (0xffff00, "Turquoise"),
142 (0xffcc00, "Sky Blue"),
143 (0x663399, "Plum"),
144 (0xc0c0c0, "Gray-25%"),
145 (0xcc99ff, "Rose"),
146 (0x99ccff, "Tan"),
147 (0x99ffff, "Light Yellow"),
148 (0xccffcc, "Light Green"),
149 (0xffffcc, "Light Turquoise"),
150 (0xffcc99, "Pale Blue"),
151 (0xff99cc, "Lavender"),
152 (0xffffff, "White"),
153];
154
155/// Highlight Color's swatches as COLORREFs with their names, which differ from the
156/// highlighter's in Sky Blue and Dark Green.
157const HIGHLIGHTS: [(u32, &str); 15] = [
158 (0x00ffff, "Yellow"),
159 (0x00ff00, "Bright Green"),
160 (0xffcc00, "Sky Blue"),
161 (0xff00ff, "Pink"),
162 (0xff0000, "Blue"),
163 (0x0000ff, "Red"),
164 (0x800000, "Dark Blue"),
165 (0x808000, "Teal"),
166 (0x003300, "Dark Green"),
167 (0x800080, "Violet"),
168 (0x000080, "Dark Red"),
169 (0x008080, "Dark Yellow"),
170 (0x808080, "Gray-50%"),
171 (0xc0c0c0, "Gray-25%"),
172 (0x000000, "Black"),
60];173];
61174
62/// The Customize Tags dialog's list while it is open.175/// The Customize Tags dialog's list while it is open.
...@@ -67,6 +180,15 @@ pub struct TagList {...@@ -67,6 +180,15 @@ pub struct TagList {
67 editor: Option<(NoteTag, Option<usize>)>,180 editor: Option<(NoteTag, Option<usize>)>,
68}181}
69182
183/// What the Symbol gallery picked.
184enum Pick {
185 Symbol(u16),
186 /// One of `EXTRAS`.
187 Extra(usize),
188 /// Custom Image…, which asks for a picture.
189 Picture,
190}
191
70fn id() -> Id {192fn id() -> Id {
71 Id::ROOT.child("customize-tags")193 Id::ROOT.child("customize-tags")
72}194}
...@@ -83,9 +205,13 @@ fn popup(name: &str) -> Id {...@@ -83,9 +205,13 @@ fn popup(name: &str) -> Id {
83 editor_id().child(("popup", name))205 editor_id().child(("popup", name))
84}206}
85207
86/// A tag's artwork before its name: its symbol, or none.208/// A tag's artwork: its own art, else its symbol, or none.
87fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> {209pub(crate) fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> {
88 TagIcon::of(tag.shape, false).map(tag_sources)210 tag.art
211 .as_deref()
212 .and_then(|art| canvas::gpu::art_sources(art, || art_bytes(art)))
213 .map(|[plain, _]| plain)
214 .or_else(|| TagIcon::of(tag.shape, false).map(tag_sources))
89}215}
90216
91/// The tag's name in its colours, as the list and Preview show it.217/// The tag's name in its colours, as the list and Preview show it.
...@@ -132,9 +258,13 @@ fn button(ui: &mut Ui, part: &str, text: &str, enabled: bool) -> bool {...@@ -132,9 +258,13 @@ fn button(ui: &mut Ui, part: &str, text: &str, enabled: bool) -> bool {
132 radius: 4.0,258 radius: 4.0,
133 pad: [theme.font_size * 0.75, 0.0],259 pad: [theme.font_size * 0.75, 0.0],
134 center: true,260 center: true,
261 role: Some(Role::Button),
135 ..Spec::default()262 ..Spec::default()
136 },263 },
137 );264 );
265 if let Some(node) = ui.access(ui.id(part)) {
266 node.set_disabled();
267 }
138 false268 false
139}269}
140270
...@@ -144,18 +274,20 @@ fn tool(ui: &mut Ui, part: &str, art: &'static [&'static str], tip: &str, enable...@@ -144,18 +274,20 @@ fn tool(ui: &mut Ui, part: &str, art: &'static [&'static str], tip: &str, enable
144 let text = ui.theme.text;274 let text = ui.theme.text;
145 if !enabled {275 if !enabled {
146 ui::shell::unavailable(ui, part, art, text, false);276 ui::shell::unavailable(ui, part, art, text, false);
277 ui::popup::tooltip(ui, tip, "", None);
147 return false;278 return false;
148 }279 }
149 let clicked = ui::shell::tool_button(ui, part, art, text, false).clicked;280 let clicked = ui::shell::tool_button(ui, part, art, text, None).clicked;
150 ui::popup::tooltip(ui, tip, "", None);281 ui::popup::tooltip(ui, tip, "", None);
151 clicked282 clicked
152}283}
153284
154/// A button showing `art` in its own colours, with `bar` under it, and a menu arrow; opens285/// A button `name`d, showing `art` in its own colours, with `bar` under it, and a menu
155/// popup `menu` and returns where it opens.286/// arrow; opens popup `menu` and returns where it opens.
156fn picker(287fn picker(
157 ui: &mut Ui,288 ui: &mut Ui,
158 part: &str,289 part: &str,
290 name: &str,
159 art: Option<&'static [&'static str]>,291 art: Option<&'static [&'static str]>,
160 bar: Option<[f32; 4]>,292 bar: Option<[f32; 4]>,
161 menu: Id,293 menu: Id,
...@@ -172,9 +304,15 @@ fn picker(...@@ -172,9 +304,15 @@ fn picker(
172 hover_fill: Some(theme.hover()),304 hover_fill: Some(theme.hover()),
173 border: Some(theme.chip),305 border: Some(theme.chip),
174 radius: 4.0,306 radius: 4.0,
307 role: Some(Role::Button),
175 ..Spec::default()308 ..Spec::default()
176 },309 },
177 );310 );
311 if let Some(node) = ui.access(button) {
312 node.set_label(name);
313 node.set_has_popup(accesskit::HasPopup::Menu);
314 node.set_expanded(open);
315 }
178 ui.open(316 ui.open(
179 "icon",317 "icon",
180 Spec {318 Spec {
...@@ -242,15 +380,20 @@ fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) {...@@ -242,15 +380,20 @@ fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) {
242 pad: [14.0, 10.0],380 pad: [14.0, 10.0],
243 gap: 6.0,381 gap: 6.0,
244 anchor: Some(Anchor::Dialog),382 anchor: Some(Anchor::Dialog),
383 role: Some(Role::Dialog),
245 ..Spec::default()384 ..Spec::default()
246 },385 },
247 );386 );
387 if let Some(node) = ui.access(id) {
388 node.set_label(title);
389 }
248 ui.leaf(390 ui.leaf(
249 "title",391 "title",
250 Spec {392 Spec {
251 size: [fill(), px(ROW + 4.0)],393 size: [fill(), px(ROW + 4.0)],
252 text: Some(title),394 text: Some(title),
253 bold: true,395 bold: true,
396 role: Some(Role::Heading),
254 ..Spec::default()397 ..Spec::default()
255 },398 },
256 );399 );
...@@ -307,6 +450,7 @@ impl State {...@@ -307,6 +450,7 @@ impl State {
307 border: Some(theme.chip),450 border: Some(theme.chip),
308 radius: 4.0,451 radius: 4.0,
309 pad: [2.0, 2.0],452 pad: [2.0, 2.0],
453 role: Some(Role::List),
310 ..Spec::default()454 ..Spec::default()
311 },455 },
312 );456 );
...@@ -321,9 +465,13 @@ impl State {...@@ -321,9 +465,13 @@ impl State {
321 fill: selected.then_some(theme.selection),465 fill: selected.then_some(theme.selection),
322 hover_fill: Some(theme.hover()),466 hover_fill: Some(theme.hover()),
323 radius: 3.0,467 radius: 3.0,
468 role: Some(Role::ListItem),
324 ..Spec::default()469 ..Spec::default()
325 },470 },
326 );471 );
472 if let Some(node) = ui.access(row) {
473 node.set_selected(selected);
474 }
327 // OneNote's list marks a tag without a symbol with its "None" symbol art.475 // OneNote's list marks a tag without a symbol with its "None" symbol art.
328 match artwork(tag) {476 match artwork(tag) {
329 Some(art) => icon(ui, "icon", Some(art), [1.0; 4]),477 Some(art) => icon(ui, "icon", Some(art), [1.0; 4]),
...@@ -420,6 +568,7 @@ impl State {...@@ -420,6 +568,7 @@ impl State {
420 shape: 0,568 shape: 0,
421 color: None,569 color: None,
422 highlight: None,570 highlight: None,
571 art: None,
423 };572 };
424 list.editor = Some((tag, None));573 list.editor = Some((tag, None));
425 } else if let Some(place) = modify {574 } else if let Some(place) = modify {
...@@ -429,7 +578,8 @@ impl State {...@@ -429,7 +578,8 @@ impl State {
429 ui.open_popup(editor_id());578 ui.open_popup(editor_id());
430 ui.set_focus(Some(name_field()));579 ui.set_focus(Some(name_field()));
431 }580 }
432 if let Some((tag, place)) = tag_editor(ui, &mut list.editor) {581 let mut picking = false;
582 if let Some((tag, place)) = tag_editor(ui, &mut list.editor, &mut picking) {
433 match place {583 match place {
434 Some(place) => list.tags[place] = tag,584 Some(place) => list.tags[place] = tag,
435 None => {585 None => {
...@@ -439,6 +589,9 @@ impl State {...@@ -439,6 +589,9 @@ impl State {
439 }589 }
440 }590 }
441 ui.close();591 ui.close();
592 if picking {
593 self.commands.push(crate::Command::TagPicture);
594 }
442 if ok {595 if ok {
443 self.tags = list.tags.clone();596 self.tags = list.tags.clone();
444 self.save_settings();597 self.save_settings();
...@@ -452,11 +605,64 @@ impl State {...@@ -452,11 +605,64 @@ impl State {
452 }605 }
453}606}
454607
608impl State {
609 /// Asks for a picture, a PNG or SVG, for the tag New Tag or Modify Tag edits, which then
610 /// draws with it; OneNote shows the tag's symbol, or a blue circle where it has none.
611 pub(crate) fn pick_tag_picture(&mut self) {
612 let Some((tag, _)) = self.tag_list.as_mut().and_then(|list| list.editor.as_mut()) else {
613 return;
614 };
615 let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else {
616 return;
617 };
618 let kept = std::fs::read(&path)
619 .ok()
620 .and_then(|bytes| canvas::gpu::import_tag_art(&bytes));
621 let Some((bytes, extension)) = kept else {
622 return platform::alert("Couldn't use the picture", "Choose a PNG or SVG picture.");
623 };
624 if bytes.len() > notebook::sidecar::LIMIT {
625 return platform::alert("Couldn't use the picture", "Choose an SVG of 1 MB or less.");
626 }
627 let art = art_name(&bytes, extension);
628 let written = pictures()
629 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
630 .and_then(|folder| {
631 std::fs::create_dir_all(&folder)?;
632 match std::fs::File::create_new(folder.join(&art)) {
633 Ok(mut file) => std::io::Write::write_all(&mut file, &bytes),
634 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
635 Err(error) => Err(error),
636 }
637 });
638 if let Err(error) = written {
639 return platform::alert("Couldn't keep the picture", &error.to_string());
640 }
641 tag.art = Some(art);
642 if tag.shape == 0 {
643 tag.shape = PICTURE_FALLBACK;
644 }
645 }
646
647 /// Has the open notebook draw the tag at `place` with its art, as Snowbound draws it
648 /// wherever the notebook opens.
649 pub(crate) fn keep_tag_art(&self, place: usize) {
650 let (Some(tag), Some(session)) = (self.tags.get(place), &self.session) else {
651 return;
652 };
653 if let Some(bytes) = tag.art.as_deref().and_then(art_bytes) {
654 session.library.map_tag_art(tag, bytes);
655 }
656 }
657}
658
455/// Builds the New Tag or Modify Tag dialog while it is open over Customize Tags. Returns the659/// Builds the New Tag or Modify Tag dialog while it is open over Customize Tags. Returns the
456/// tag and the place it replaces once OK keeps it.660/// tag and the place it replaces once OK keeps it; sets `picking` once Custom Image… asks
661/// for a picture.
457fn tag_editor(662fn tag_editor(
458 ui: &mut Ui,663 ui: &mut Ui,
459 editor: &mut Option<(NoteTag, Option<usize>)>,664 editor: &mut Option<(NoteTag, Option<usize>)>,
665 picking: &mut bool,
460) -> Option<(NoteTag, Option<usize>)> {666) -> Option<(NoteTag, Option<usize>)> {
461 if !ui.popup_open(editor_id()) {667 if !ui.popup_open(editor_id()) {
462 *editor = None;668 *editor = None;
...@@ -506,6 +712,9 @@ fn tag_editor(...@@ -506,6 +712,9 @@ fn tag_editor(
506 ..Spec::default()712 ..Spec::default()
507 },713 },
508 );714 );
715 if let Some(node) = ui.access(name_field()) {
716 node.set_label("Display name");
717 }
509 ui.open(718 ui.open(
510 "pickers",719 "pickers",
511 Spec {720 Spec {
...@@ -535,10 +744,31 @@ fn tag_editor(...@@ -535,10 +744,31 @@ fn tag_editor(
535 };744 };
536 column(ui, "symbol", "Symbol:");745 column(ui, "symbol", "Symbol:");
537 let symbol = artwork(tag).unwrap_or(crate::art::FONT_COLOR);746 let symbol = artwork(tag).unwrap_or(crate::art::FONT_COLOR);
538 let tint = if tag.shape == 0 { theme.text } else { [1.0; 4] };747 let tint = if artwork(tag).is_none() {
539 let anchor = picker(ui, "picker", Some(symbol), None, popup("symbol"), tint);748 theme.text
540 if let Some(shape) = gallery(ui, anchor) {749 } else {
541 tag.shape = shape;750 [1.0; 4]
751 };
752 let anchor = picker(
753 ui,
754 "picker",
755 "Symbol",
756 Some(symbol),
757 None,
758 popup("symbol"),
759 tint,
760 );
761 match gallery(ui, popup("symbol"), anchor, true) {
762 Some(Pick::Symbol(shape)) => {
763 tag.shape = shape;
764 tag.art = None;
765 }
766 Some(Pick::Extra(extra)) => {
767 tag.shape = EXTRAS[extra].1;
768 tag.art = Some(extra_art()[extra].clone());
769 }
770 Some(Pick::Picture) => *picking = true,
771 None => {}
542 }772 }
543 ui.close();773 ui.close();
544 column(ui, "color", "Font Color:");774 column(ui, "color", "Font Color:");
...@@ -546,6 +776,7 @@ fn tag_editor(...@@ -546,6 +776,7 @@ fn tag_editor(
546 let anchor = picker(776 let anchor = picker(
547 ui,777 ui,
548 "picker",778 "picker",
779 "Font Color",
549 Some(crate::art::FONT_COLOR),780 Some(crate::art::FONT_COLOR),
550 Some(bar),781 Some(bar),
551 popup("color"),782 popup("color"),
...@@ -560,6 +791,7 @@ fn tag_editor(...@@ -560,6 +791,7 @@ fn tag_editor(
560 let anchor = picker(791 let anchor = picker(
561 ui,792 ui,
562 "picker",793 "picker",
794 "Highlight Color",
563 Some(crate::art::HIGHLIGHTER),795 Some(crate::art::HIGHLIGHTER),
564 bar,796 bar,
565 popup("highlight"),797 popup("highlight"),
...@@ -570,6 +802,45 @@ fn tag_editor(...@@ -570,6 +802,45 @@ fn tag_editor(
570 }802 }
571 ui.close();803 ui.close();
572 ui.close();804 ui.close();
805 // A symbol of Snowbound's own names the one OneNote shows in its place.
806 if tag.art.is_some() {
807 ui.open(
808 "fallback-row",
809 Spec {
810 size: [fill(), children()],
811 gap: 10.0,
812 ..Spec::default()
813 },
814 );
815 column(ui, "fallback", "In OneNote:");
816 let anchor = picker(
817 ui,
818 "picker",
819 "In OneNote",
820 TagIcon::of(tag.shape, false).map(tag_sources),
821 None,
822 popup("fallback"),
823 [1.0; 4],
824 );
825 if let Some(Pick::Symbol(shape)) = gallery(ui, popup("fallback"), anchor, false)
826 && shape != 0
827 {
828 tag.shape = shape;
829 }
830 ui.close();
831 ui.leaf(
832 "onenote",
833 Spec {
834 size: [fill(), fit()],
835 text: Some("OneNote shows this symbol in place of the art."),
836 color: Some(theme.text_dim),
837 overflow: ui::Overflow::Wrap,
838 pad: [0.0, ROW],
839 ..Spec::default()
840 },
841 );
842 ui.close();
843 }
573 caption(ui, "preview-label", "Preview");844 caption(ui, "preview-label", "Preview");
574 ui.open(845 ui.open(
575 "preview",846 "preview",
...@@ -629,9 +900,10 @@ fn tag_editor(...@@ -629,9 +900,10 @@ fn tag_editor(
629 None900 None
630}901}
631902
632/// Symbol's gallery below `anchor` while it is open. Returns the symbol picked, 0 for None.903/// Popup `id`, a gallery of OneNote's symbols below `anchor`, while it is open; with
633fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {904/// `snowbound`, also None, Snowbound's own symbols and Custom Image…. Returns the pick,
634 let id = popup("symbol");905/// symbol 0 for None.
906fn gallery(ui: &mut Ui, id: Id, anchor: Anchor, snowbound: bool) -> Option<Pick> {
635 if !ui.popup_open(id) {907 if !ui.popup_open(id) {
636 return None;908 return None;
637 }909 }
...@@ -647,9 +919,13 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {...@@ -647,9 +919,13 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
647 radius: 6.0,919 radius: 6.0,
648 pad: [6.0, 6.0],920 pad: [6.0, 6.0],
649 anchor: Some(anchor),921 anchor: Some(anchor),
922 role: Some(Role::Dialog),
650 ..Spec::default()923 ..Spec::default()
651 },924 },
652 );925 );
926 if let Some(node) = ui.access(id) {
927 node.set_label("Symbol");
928 }
653 let mut picked = None;929 let mut picked = None;
654 for (row, shapes) in GALLERY.iter().enumerate() {930 for (row, shapes) in GALLERY.iter().enumerate() {
655 ui.open(931 ui.open(
...@@ -678,7 +954,7 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {...@@ -678,7 +954,7 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
678 if shape == 0 && row == 0 && column > 0 {954 if shape == 0 && row == 0 && column > 0 {
679 continue;955 continue;
680 }956 }
681 let none = shape == 0 && row == 0;957 let none = shape == 0 && row == 0 && snowbound;
682 let spec = if none {958 let spec = if none {
683 Spec {959 Spec {
684 flags: Flags::CLICKABLE,960 flags: Flags::CLICKABLE,
...@@ -687,11 +963,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {...@@ -687,11 +963,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
687 hover_fill: Some(theme.hover()),963 hover_fill: Some(theme.hover()),
688 radius: 3.0,964 radius: 3.0,
689 center: true,965 center: true,
966 role: Some(Role::Button),
690 ..Spec::default()967 ..Spec::default()
691 }968 }
692 } else if shape == 0 {969 } else if shape == 0 {
693 Spec {970 Spec {
694 size: [px(CELL), px(CELL)],971 size: [px(if row == 0 { CELL * 3.0 } else { CELL }), px(CELL)],
695 ..Spec::default()972 ..Spec::default()
696 }973 }
697 } else {974 } else {
...@@ -703,11 +980,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {...@@ -703,11 +980,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
703 hover_fill: Some(theme.hover()),980 hover_fill: Some(theme.hover()),
704 radius: 3.0,981 radius: 3.0,
705 center: true,982 center: true,
983 role: Some(Role::Button),
706 ..Spec::default()984 ..Spec::default()
707 }985 }
708 };986 };
709 if ui.leaf(("cell", column), spec).clicked && (none || shape != 0) {987 if ui.leaf(("cell", column), spec).clicked && (none || shape != 0) {
710 picked = Some(shape);988 picked = Some(Pick::Symbol(shape));
711 }989 }
712 if let Some(name) = symbol_name(shape) {990 if let Some(name) = symbol_name(shape) {
713 ui::popup::tooltip(ui, name, "", None);991 ui::popup::tooltip(ui, name, "", None);
...@@ -715,6 +993,64 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {...@@ -715,6 +993,64 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> {
715 }993 }
716 ui.close();994 ui.close();
717 }995 }
996 if snowbound {
997 ui.leaf(
998 "snowbound",
999 Spec {
1000 size: [fit(), px(ROW + 4.0)],
1001 text: Some("Snowbound Symbols"),
1002 color: Some(theme.text_dim),
1003 role: Some(Role::Heading),
1004 ..Spec::default()
1005 },
1006 );
1007 ui.open(
1008 "extras",
1009 Spec {
1010 size: [children(), px(CELL)],
1011 ..Spec::default()
1012 },
1013 );
1014 for (extra, (name, _, _)) in EXTRAS.iter().enumerate() {
1015 if extra % 3 == 0 && extra > 0 {
1016 ui.leaf(
1017 ("gap", extra),
1018 Spec {
1019 size: [px(8.0), px(CELL)],
1020 ..Spec::default()
1021 },
1022 );
1023 }
1024 let art = &extra_art()[extra];
1025 let icon = canvas::gpu::art_sources(art, || art_bytes(art)).map(|[plain, _]| plain);
1026 let cell = Spec {
1027 flags: Flags::CLICKABLE,
1028 size: [px(CELL), px(CELL)],
1029 icon,
1030 color: Some([1.0; 4]),
1031 hover_fill: Some(theme.hover()),
1032 radius: 3.0,
1033 center: true,
1034 role: Some(Role::Button),
1035 ..Spec::default()
1036 };
1037 if ui.leaf(("extra", extra), cell).clicked {
1038 picked = Some(Pick::Extra(extra));
1039 }
1040 ui::popup::tooltip(ui, name, "", None);
1041 }
1042 ui.close();
1043 ui.leaf(
1044 "space",
1045 Spec {
1046 size: [px(1.0), px(6.0)],
1047 ..Spec::default()
1048 },
1049 );
1050 if ui::button(ui, "picture", "Custom Image…").clicked {
1051 picked = Some(Pick::Picture);
1052 }
1053 }
718 ui.close();1054 ui.close();
719 if picked.is_some() {1055 if picked.is_some() {
720 ui.close_popup(id);1056 ui.close_popup(id);
...@@ -729,17 +1065,20 @@ fn swatch(...@@ -729,17 +1065,20 @@ fn swatch(
729 name: &str,1065 name: &str,
730 anchor: Anchor,1066 anchor: Anchor,
731 none: &str,1067 none: &str,
732 swatches: &[u32],1068 swatches: &[(u32, &str)],
733 columns: usize,1069 columns: usize,
734) -> Option<Option<u32>> {1070) -> Option<Option<u32>> {
735 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();1071 let colors: Vec<_> = swatches
1072 .iter()
1073 .map(|&(color, name)| (colorref(color), name))
1074 .collect();
736 let chosen = ui::popup::colors(ui, popup(name), anchor, none, &colors, columns)?;1075 let chosen = ui::popup::colors(ui, popup(name), anchor, none, &colors, columns)?;
737 Some(chosen.and_then(|chosen| {1076 Some(chosen.and_then(|chosen| {
738 swatches1077 swatches
739 .iter()1078 .iter()
740 .zip(&colors)1079 .zip(&colors)
741 .find(|(_, color)| **color == chosen)1080 .find(|(_, (color, _))| *color == chosen)
742 .map(|(stored, _)| *stored)1081 .map(|((stored, _), _)| *stored)
743 }))1082 }))
744}1083}
7451084
...@@ -747,11 +1086,238 @@ fn swatch(...@@ -747,11 +1086,238 @@ fn swatch(
747mod tests {1086mod tests {
748 use super::*;1087 use super::*;
7491088
1089 /// A 48-pixel picture as a PNG: a violet disc ringed in gold, standing for one a user
1090 /// chose.
1091 fn picture() -> Vec<u8> {
1092 let mut pixels = Vec::new();
1093 for y in 0..48 {
1094 for x in 0..48 {
1095 let distance = ((x as f32 - 23.5).powi(2) + (y as f32 - 23.5).powi(2)).sqrt();
1096 pixels.extend_from_slice(match distance {
1097 ..16.0 => &[120, 60, 220, 255],
1098 ..22.0 => &[240, 190, 40, 255],
1099 _ => &[0, 0, 0, 0],
1100 });
1101 }
1102 }
1103 let mut encoded = Vec::new();
1104 let mut encoder = png::Encoder::new(&mut encoded, 48, 48);
1105 encoder.set_color(png::ColorType::Rgba);
1106 encoder
1107 .write_header()
1108 .unwrap()
1109 .write_image_data(&pixels)
1110 .unwrap();
1111 encoded
1112 }
1113
1114 /// Tags with Snowbound's art keep OneNote's definition, name and fallback symbol, on the
1115 /// page, and their art in the notebook's `.snowbound` folder, from which the page draws
1116 /// them (`corpus/custom-art`). `SNOWBOUND_CUSTOM_ART_EXPORT` names a new directory
1117 /// receiving the notebook for a cold reopen.
1118 #[test]
1119 fn tags_with_art_keep_it_beside_the_page() {
1120 use canvas::{
1121 document::TextPosition,
1122 editor::{CanvasEditor, Formatting},
1123 layout::TextEngine,
1124 };
1125 use onestore::{RevisionIndex, Store, document::Document, op, page::Page};
1126 let page = |bytes: &[u8]| {
1127 let store = Store::parse(bytes).unwrap();
1128 let index = RevisionIndex::parse(&store).unwrap();
1129 let document = Document::parse(&index).unwrap();
1130 let (space, _) = document.pages().unwrap()[0];
1131 (space, Page::from_space(&document, space).unwrap())
1132 };
1133 let (picture, extension) = canvas::gpu::import_tag_art(&picture()).unwrap();
1134 let rocket = EXTRAS
1135 .iter()
1136 .position(|(name, ..)| *name == "Rocket")
1137 .unwrap();
1138 let tag = |label: &str, shape, art: Option<String>| NoteTag {
1139 label: label.into(),
1140 shape,
1141 color: None,
1142 highlight: None,
1143 art,
1144 };
1145 let list = [
1146 tag("Launch", 13, Some(art_name(&picture, extension))),
1147 tag(
1148 "Ship it",
1149 EXTRAS[rocket].1,
1150 Some(extra_art()[rocket].clone()),
1151 ),
1152 tag("Done", 3, Some(extra_art()[rocket].clone())),
1153 NoteTag::defaults()[1].clone(),
1154 ];
1155 let lines = [
1156 "Launch day",
1157 "Ship the release",
1158 "Checked off",
1159 "Plain OneNote tag",
1160 ];
1161 let source = onestore::create_section("Custom art.one", "", "Author").unwrap();
1162 let (space, before) = page(&source);
1163 let mut engine = TextEngine::default();
1164 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();
1165 let body = editor.outlines()[0].id;
1166 editor.focus_outline(body).unwrap();
1167 for (paragraph, text) in lines.iter().enumerate() {
1168 if paragraph > 0 {
1169 editor.enter(&mut engine, false).unwrap();
1170 }
1171 editor.insert(&mut engine, text).unwrap();
1172 editor
1173 .select(
1174 [TextPosition {
1175 paragraph,
1176 offset: 0,
1177 }; 2]
1178 .into(),
1179 )
1180 .unwrap();
1181 let formatting = Formatting::Tag(list[paragraph].clone(), paragraph as u16);
1182 editor.format(&mut engine, formatting).unwrap();
1183 if paragraph == 2 {
1184 editor.format(&mut engine, Formatting::Check).unwrap();
1185 }
1186 let offset = text.len() as u32;
1187 editor
1188 .select([TextPosition { paragraph, offset }; 2].into())
1189 .unwrap();
1190 }
1191 let arena = onestore::Arena::default();
1192 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
1193 let ops = editor
1194 .take_ops()
1195 .unwrap()
1196 .into_iter()
1197 .map(|op| op::Op::Page { space, op })
1198 .collect();
1199 let edit = op::Edit {
1200 at: 134_000_000_000_000_000,
1201 ops,
1202 };
1203 section.apply("Author", &edit).unwrap();
1204 section.seal().unwrap();
1205 let written = section.image();
1206 let temporary =
1207 std::env::temp_dir().join(format!("snowbound-custom-art-{}", std::process::id()));
1208 let _ = std::fs::remove_dir_all(&temporary);
1209 let root = temporary.join("Custom Art");
1210 std::fs::create_dir_all(&root).unwrap();
1211 std::fs::write(root.join("Custom art.one"), &written).unwrap();
1212 let file_id = Store::parse(&written).unwrap().header.file_id;
1213 let toc = onestore::create_table_of_contents(
1214 "Open Notebook.onetoc2",
1215 &[("Custom art.one", file_id)],
1216 );
1217 std::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap();
1218 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache"));
1219 let notebook = notebook.unwrap();
1220 for tag in &list[..3] {
1221 let art = tag.art.as_deref().unwrap();
1222 let bytes = art_bytes(art).unwrap_or_else(|| picture.clone());
1223 let extension = art.rsplit('.').next().unwrap();
1224 notebook
1225 .map_tag_art(&tag.label, tag.shape, &bytes, extension)
1226 .unwrap();
1227 }
1228 // The page as OneNote stores it, drawn with the notebook's art.
1229 let mut art = canvas::gpu::TagArt::default();
1230 for mapping in notebook.tag_art().unwrap() {
1231 let bytes = || notebook.tag_art_file(&mapping.art).ok();
1232 let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap();
1233 art.map(&mapping.name, mapping.shape, &mapping.art, sources);
1234 }
1235 let (_, stored) = page(&written);
1236 let reread = CanvasEditor::from_page(stored, &mut engine).unwrap();
1237 let outline = reread.outlines().iter().find(|outline| outline.id == body);
1238 let drawn: Vec<_> = outline
1239 .unwrap()
1240 .layouts()
1241 .flat_map(|(_, paragraph)| &paragraph.tags)
1242 .map(|tag| (tag.label.clone(), art.sources(tag)))
1243 .collect();
1244 let picture_art = canvas::gpu::art_sources(list[0].art.as_ref().unwrap(), || None);
1245 let rocket_art = canvas::gpu::art_sources(&extra_art()[rocket], || None).unwrap();
1246 let important = tag_sources(TagIcon::of(13, false).unwrap());
1247 assert_eq!(
1248 drawn,
1249 [
1250 ("Launch".to_owned(), picture_art.unwrap()[0]),
1251 ("Ship it".to_owned(), rocket_art[0]),
1252 ("Done".to_owned(), rocket_art[1]),
1253 ("Important".to_owned(), important),
1254 ]
1255 );
1256 if let Some(directory) = std::env::var_os("SNOWBOUND_CUSTOM_ART_EXPORT") {
1257 let status = std::process::Command::new("cp")
1258 .arg("-R")
1259 .arg(&root)
1260 .arg(directory)
1261 .status()
1262 .unwrap();
1263 assert!(status.success());
1264 }
1265 std::fs::remove_dir_all(&temporary).unwrap();
1266 }
1267
750 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but1268 /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but
751 /// the follow-up flags it keeps for Outlook tasks.1269 /// the follow-up flags it keeps for Outlook tasks.
752 /// OneNote 2010's gallery, cell by cell: its page "Cells" tags a paragraph with a tag1270 /// OneNote 2010's gallery, cell by cell: its page "Cells" tags a paragraph with a tag
753 /// named for each cell's row and column, its symbol picked from that cell1271 /// named for each cell's row and column, its symbol picked from that cell
754 /// (`corpus/custom-tags/native`).1272 /// (`corpus/custom-tags/native`).
1273 /// OneNote 2010 edited the page of `corpus/custom-art` (new text on one tagged paragraph,
1274 /// another's check box cleared); the notebook's art still draws each tag it mapped.
1275 #[test]
1276 fn art_outlasts_onenote_editing_the_page() {
1277 use canvas::{editor::CanvasEditor, layout::TextEngine};
1278 use onestore::{RevisionIndex, Store, document::Document, page::Page};
1279 let corpus =
1280 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/custom-art");
1281 let cache =
1282 std::env::temp_dir().join(format!("snowbound-custom-art-read-{}", std::process::id()));
1283 let notebook = notebook::session::Notebook::open(corpus.join("candidate"), &cache).unwrap();
1284 let mut art = canvas::gpu::TagArt::default();
1285 for mapping in notebook.tag_art().unwrap() {
1286 let bytes = || notebook.tag_art_file(&mapping.art).ok();
1287 let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap();
1288 art.map(&mapping.name, mapping.shape, &mapping.art, sources);
1289 }
1290 std::fs::remove_dir_all(&cache).unwrap();
1291 let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap();
1292 let store = Store::parse(&bytes).unwrap();
1293 let index = RevisionIndex::parse(&store).unwrap();
1294 let document = Document::parse(&index).unwrap();
1295 let (space, _) = document.pages().unwrap()[0];
1296 let page = Page::from_space(&document, space).unwrap();
1297 let editor = CanvasEditor::from_page(page, &mut TextEngine::default()).unwrap();
1298 let drawn: Vec<_> = editor
1299 .outlines()
1300 .iter()
1301 .flat_map(|outline| outline.layouts())
1302 .flat_map(|(_, paragraph)| &paragraph.tags)
1303 .map(|tag| {
1304 (
1305 tag.label.as_str(),
1306 art.sources(tag) != tag_sources(tag.icon),
1307 )
1308 })
1309 .collect();
1310 assert_eq!(
1311 drawn,
1312 [
1313 ("Launch", true),
1314 ("Ship it", true),
1315 ("Done", true),
1316 ("Important", false),
1317 ]
1318 );
1319 }
1320
755 #[test]1321 #[test]
756 fn the_gallery_lays_symbols_out_as_onenotes_does() {1322 fn the_gallery_lays_symbols_out_as_onenotes_does() {
757 use onestore::{1323 use onestore::{
crates/snowbound/src/templates.rs+8-1
...@@ -166,9 +166,11 @@ fn strip(...@@ -166,9 +166,11 @@ fn strip(
166 border: Some(theme.chip),166 border: Some(theme.chip),
167 hover_border: Some(theme.accent),167 hover_border: Some(theme.accent),
168 radius: 4.0,168 radius: 4.0,
169 role: Some(accesskit::Role::Button),
169 ..Spec::default()170 ..Spec::default()
170 },171 },
171 );172 );
173 crate::name(ui, ui.id((index, *name)), name);
172 if swatch.clicked {174 if swatch.clicked {
173 chosen = Some(Choice::Color(index));175 chosen = Some(Choice::Color(index));
174 }176 }
...@@ -191,9 +193,11 @@ fn strip(...@@ -191,9 +193,11 @@ fn strip(
191 hover_fill: Some(theme.hover()),193 hover_fill: Some(theme.hover()),
192 radius: 11.0,194 radius: 11.0,
193 center: true,195 center: true,
196 role: Some(accesskit::Role::Button),
194 ..Spec::default()197 ..Spec::default()
195 },198 },
196 );199 );
200 crate::name(ui, ui.id("dismiss"), "Close");
197 if close.clicked {201 if close.clicked {
198 chosen = Some(Choice::Dismiss);202 chosen = Some(Choice::Dismiss);
199 }203 }
...@@ -244,9 +248,10 @@ fn gallery(...@@ -244,9 +248,10 @@ fn gallery(
244 ..Spec::default()248 ..Spec::default()
245 },249 },
246 );250 );
247 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, false).clicked {251 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, None).clicked {
248 chosen = Some(Choice::Dismiss);252 chosen = Some(Choice::Dismiss);
249 }253 }
254 crate::name(ui, ui.id("close"), "Close");
250 ui.close();255 ui.close();
251 ui.open(256 ui.open(
252 "grid",257 "grid",
...@@ -351,9 +356,11 @@ fn tile(...@@ -351,9 +356,11 @@ fn tile(
351 icon: (choice == Choice::More).then_some(art::PLUS),356 icon: (choice == Choice::More).then_some(art::PLUS),
352 color: Some(theme.text_dim),357 color: Some(theme.text_dim),
353 center: true,358 center: true,
359 role: Some(accesskit::Role::Button),
354 ..Spec::default()360 ..Spec::default()
355 },361 },
356 );362 );
363 crate::name(ui, id, label);
357 match choice {364 match choice {
358 Choice::Template(name) => {365 Choice::Template(name) => {
359 if let Some(template) = canvas::template::find(name) {366 if let Some(template) = canvas::template::find(name) {
crates/snowbound/src/update.rs created+916
...@@ -0,0 +1,916 @@
1//! Snowbound updating itself. Every published build keeps its own folder under `BASE`, with a
2//! `build.json` the release key signs; `latest.json` names each platform's newest build. A
3//! thread checks on launch and daily, downloads a newer build for this platform, verifies
4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.
5//! `tools/RELEASE.md` describes the publishing side.
6
7use crate::{State, UserEvent, platform};
8use ring::signature::{ED25519, UnparsedPublicKey};
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16use winit::event_loop::EventLoopProxy;
17
18/// Where the builds are published.
19const BASE: &str = "https://file.paperclover.net/shr/snowbound/";
20
21/// The release key's public half, in hex.
22const KEY: &str = include_str!("../release-key.pub");
23
24/// The argument `relaunch` starts the old executable with to finish an update.
25pub const FINISH: &str = "--finish-update";
26
27const DAY: Duration = Duration::from_secs(24 * 60 * 60);
28
29/// This build's version as `tools/release.py` stamped it; development builds have none.
30fn running() -> Option<Version> {
31 Version::parse(option_env!("SNOWBOUND_BUILD")?)
32}
33
34/// This build's platform, as `latest.json` and `build.json` key their entries.
35fn platform() -> String {
36 if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {
37 "macos-10.6".to_owned()
38 } else {
39 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
40 }
41}
42
43/// A published build: the day of its commit on `main`, in Los Angeles, and how many of that
44/// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests.
45#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
46pub struct Version {
47 date: String,
48 revision: u32,
49}
50
51impl Version {
52 fn parse(name: &str) -> Option<Self> {
53 let (date, revision) = name.split_once("-r")?;
54 let digits = |range: std::ops::Range<usize>| {
55 date.get(range)
56 .is_some_and(|part| part.bytes().all(|byte| byte.is_ascii_digit()))
57 };
58 let dated = date.len() == 10
59 && digits(0..4)
60 && digits(5..7)
61 && digits(8..10)
62 && &date[4..5] == "-"
63 && &date[7..8] == "-";
64 let revision = revision.parse().ok().filter(|&revision| revision > 0)?;
65 dated.then(|| Self {
66 date: date.to_owned(),
67 revision,
68 })
69 }
70
71 fn name(&self) -> String {
72 format!("{}-r{}", self.date, self.revision)
73 }
74
75 /// The build's folder under `BASE`.
76 fn folder(&self) -> String {
77 format!("{}.r{}/", self.date, self.revision)
78 }
79}
80
81impl std::fmt::Display for Version {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "build {} revision {}", self.date, self.revision)
84 }
85}
86
87/// The running build as Options and About name it.
88pub fn describe_running() -> String {
89 running().map_or_else(
90 || "Snowbound development build".to_owned(),
91 |version| format!("Snowbound {version}"),
92 )
93}
94
95#[derive(Deserialize)]
96struct Build {
97 version: String,
98 archives: HashMap<String, Archive>,
99}
100
101#[derive(Clone, Debug, Deserialize)]
102struct Archive {
103 file: String,
104 size: u64,
105 sha256: String,
106 /// The release key's signature of the archive's bytes.
107 signature: String,
108}
109
110fn unhex(text: &str) -> Option<Vec<u8>> {
111 let text = text.trim();
112 text.len()
113 .is_multiple_of(2)
114 .then(|| {
115 (0..text.len())
116 .step_by(2)
117 .map(|at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok())
118 .collect()
119 })
120 .flatten()
121}
122
123fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {
124 let signature = unhex(signature).ok_or("The signature isn’t hex")?;
125 UnparsedPublicKey::new(&ED25519, key)
126 .verify(message, &signature)
127 .map_err(|_| "The signature doesn’t match the release key".to_owned())
128}
129
130/// The version `latest.json` names for `platform`, where it is newer than `running`.
131fn newer(
132 latest: &[u8],
133 platform: &str,
134 running: Option<&Version>,
135) -> Result<Option<Version>, String> {
136 let latest: HashMap<String, String> =
137 serde_json::from_slice(latest).map_err(|error| format!("latest.json: {error}"))?;
138 let Some(name) = latest.get(platform) else {
139 return Ok(None);
140 };
141 let version = Version::parse(name).ok_or_else(|| format!("latest.json names {name}"))?;
142 Ok(running
143 .is_none_or(|running| version > *running)
144 .then_some(version))
145}
146
147/// `platform`'s archive in the build `build.json` describes, once its signature holds and
148/// it describes `version`.
149fn archive(
150 key: &[u8],
151 build: &[u8],
152 signature: &[u8],
153 version: &Version,
154 platform: &str,
155) -> Result<Archive, String> {
156 verify(key, build, &String::from_utf8_lossy(signature))?;
157 let build: Build =
158 serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?;
159 if Version::parse(&build.version).as_ref() != Some(version) {
160 return Err(format!("build.json describes {}", build.version));
161 }
162 build
163 .archives
164 .get(platform)
165 .cloned()
166 .ok_or_else(|| format!("{} has no {platform} archive", version.name()))
167}
168
169fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> {
170 if bytes.len() as u64 != archive.size {
171 return Err(format!(
172 "{} is {} bytes, not {}",
173 archive.file,
174 bytes.len(),
175 archive.size
176 ));
177 }
178 let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
179 if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) {
180 return Err(format!("{}’s SHA-256 doesn’t match", archive.file));
181 }
182 verify(key, bytes, &archive.signature)
183}
184
185/// What an update replaces: the app bundle on macOS, the executable elsewhere. Development
186/// builds and Mac OS X 10.6 have none, and point to the build's folder instead.
187fn install() -> Option<PathBuf> {
188 running()?;
189 let executable = std::env::current_exe().ok()?;
190 if !cfg!(feature = "wgpu") {
191 None
192 } else if cfg!(target_os = "macos") {
193 let bundle = executable.ancestors().nth(3)?;
194 (bundle.extension()? == "app").then(|| bundle.to_owned())
195 } else {
196 Some(executable)
197 }
198}
199
200/// The folder an update to `install` unpacks into, beside it so the swap is a rename.
201fn staging(install: &Path) -> Option<PathBuf> {
202 let name = install.file_name()?.to_string_lossy();
203 Some(install.with_file_name(format!(".{name}.update")))
204}
205
206/// Where in `folder` a staged update keeps what replaces the install.
207fn staged(folder: &Path) -> PathBuf {
208 folder.join(if cfg!(target_os = "macos") {
209 "Snowbound.app"
210 } else {
211 "snowbound"
212 })
213}
214
215/// Stages a verified archive in `folder`, replacing what was there: the zipped app unpacked
216/// on macOS, the executable itself elsewhere. Names the version last, so a folder naming
217/// `version` holds all of it.
218fn stage(bytes: &[u8], folder: &Path, version: &Version) -> std::io::Result<PathBuf> {
219 match std::fs::remove_dir_all(folder) {
220 Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
221 _ => {}
222 }
223 std::fs::create_dir(folder)?;
224 let item = staged(folder);
225 if cfg!(target_os = "macos") {
226 let file = folder.join("archive.zip");
227 std::fs::write(&file, bytes)?;
228 let unpacked = Command::new("/usr/bin/ditto")
229 .args(["-x", "-k"])
230 .arg(&file)
231 .arg(folder)
232 .status()?;
233 std::fs::remove_file(&file)?;
234 if !unpacked.success() || !item.exists() {
235 return Err(std::io::Error::other("The archive holds no Snowbound.app"));
236 }
237 } else {
238 std::fs::write(&item, bytes)?;
239 std::fs::set_permissions(&item, std::os::unix::fs::PermissionsExt::from_mode(0o755))?;
240 }
241 std::fs::write(folder.join("version"), version.name())?;
242 Ok(item)
243}
244
245/// Swaps `staged` in for `install`, putting the old bundle back if the new one can't take its
246/// place, then removes the staging folder.
247fn apply(staged: &Path, install: &Path) -> std::io::Result<()> {
248 let folder = staging(install).ok_or_else(|| std::io::Error::other("No install name"))?;
249 if install.is_dir() {
250 let old = folder.with_extension("old");
251 let _ = std::fs::remove_dir_all(&old);
252 std::fs::rename(install, &old)?;
253 if let Err(error) = std::fs::rename(staged, install) {
254 std::fs::rename(&old, install)?;
255 return Err(error);
256 }
257 let _ = std::fs::remove_dir_all(&old);
258 } else {
259 std::fs::rename(staged, install)?;
260 }
261 let _ = std::fs::remove_dir_all(folder);
262 Ok(())
263}
264
265/// Where an update check has got to.
266#[derive(Clone, Debug, Default, PartialEq)]
267pub enum Status {
268 #[default]
269 Idle,
270 UpToDate,
271 Downloading(Version),
272 /// Verified and unpacked beside the install, waiting for Restart to Update.
273 Ready(Version, PathBuf),
274 /// Newer than this build, which can't install it itself: its folder has the download.
275 Available(Version),
276 Failed(&'static str),
277}
278
279const UNREACHABLE: &str =
280 "Snowbound couldn’t reach the update server. Check your connection, then try again.";
281const UNVERIFIED: &str =
282 "The update didn’t match Snowbound’s release signature, so it wasn’t installed.";
283
284/// Fetches a path under `BASE`, refusing a body over the limit in bytes.
285type Fetch<'a> = dyn Fn(&str, u64) -> Result<Vec<u8>, String> + 'a;
286
287/// Looks for a build newer than `running` and stages it beside `install` if there is one.
288fn check(
289 get: &Fetch<'_>,
290 key: &[u8],
291 running: Option<&Version>,
292 install: Option<&Path>,
293 progress: &dyn Fn(Status),
294) -> Status {
295 let platform = platform();
296 let fetch = |path: &str, limit| {
297 get(path, limit).map_err(|error| {
298 eprintln!("Cannot fetch {BASE}{path}: {error}");
299 UNREACHABLE
300 })
301 };
302 let unverified = |error: String| {
303 eprintln!("Rejecting the update: {error}");
304 UNVERIFIED
305 };
306 let result = (|| {
307 let Some(version) =
308 newer(&fetch("latest.json", 1 << 16)?, &platform, running).map_err(unverified)?
309 else {
310 return Ok(Status::UpToDate);
311 };
312 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;
313 let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?;
314 let archive = archive(key, &build, &signature, &version, &platform).map_err(unverified)?;
315 let Some(folder) = install.and_then(staging) else {
316 return Ok(Status::Available(version));
317 };
318 let item = staged(&folder);
319 if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name())
320 && item.exists()
321 {
322 return Ok(Status::Ready(version, item));
323 }
324 progress(Status::Downloading(version.clone()));
325 let bytes = fetch(
326 &format!("{}{}", version.folder(), archive.file),
327 archive.size,
328 )?;
329 check_archive(key, &archive, &bytes).map_err(unverified)?;
330 Ok(match stage(&bytes, &folder, &version) {
331 Ok(item) => Status::Ready(version, item),
332 Err(error) => {
333 eprintln!("Cannot stage the update in {}: {error}", folder.display());
334 Status::Available(version)
335 }
336 })
337 })();
338 result.unwrap_or_else(Status::Failed)
339}
340
341/// The `Fetch` the app uses.
342fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
343 let agent: ureq::Agent = ureq::Agent::config_builder()
344 .timeout_global(Some(Duration::from_secs(10 * 60)))
345 .timeout_connect(Some(Duration::from_secs(30)))
346 .build()
347 .into();
348 agent
349 .get(&format!("{BASE}{path}"))
350 .call()
351 .and_then(|mut response| response.body_mut().with_config().limit(limit).read_to_vec())
352 .map_err(|error| error.to_string())
353}
354
355#[derive(Default)]
356struct Shared {
357 status: Status,
358 automatic: bool,
359 /// Check for Updates… asked for a check.
360 asked: bool,
361 /// What the asked-for check found, to be shown.
362 answer: Option<Status>,
363}
364
365/// The checking thread and what it has found.
366pub struct Updates {
367 shared: Arc<Mutex<Shared>>,
368 thread: std::thread::Thread,
369 /// Restart to Update was chosen: swap the staged build in once the app quits.
370 restart: bool,
371}
372
373impl Updates {
374 /// Starts the thread, which checks on its own once `automatic` and this is a published
375 /// build, shortly after launch and then daily, while the app isn't working offline.
376 pub fn start(automatic: bool, proxy: EventLoopProxy<UserEvent>) -> Self {
377 let shared = Arc::new(Mutex::new(Shared {
378 automatic,
379 ..Shared::default()
380 }));
381 let key = unhex(KEY).expect("release-key.pub holds a key in hex");
382 let thread = {
383 let shared = Arc::clone(&shared);
384 std::thread::Builder::new()
385 .name("updates".into())
386 .spawn(move || {
387 let running = running();
388 let install = install();
389 // Launch settles before the first check.
390 std::thread::park_timeout(Duration::from_secs(10));
391 let mut last: Option<Instant> = None;
392 loop {
393 let asked = {
394 let mut shared = shared.lock().unwrap();
395 let due = shared.automatic
396 && running.is_some()
397 && !crate::library::offline()
398 && last.is_none_or(|last| last.elapsed() >= DAY);
399 let asked = std::mem::take(&mut shared.asked);
400 (asked || due).then_some(asked)
401 };
402 if let Some(asked) = asked {
403 last = Some(Instant::now());
404 // A failed check leaves what an earlier one found.
405 let set = |status: Status, asked| {
406 let mut shared = shared.lock().unwrap();
407 if asked {
408 shared.answer = Some(status.clone());
409 }
410 if !matches!(status, Status::Failed(_)) {
411 shared.status = status;
412 }
413 let _ = proxy.send_event(UserEvent::Update);
414 };
415 let status = check(
416 &download,
417 &key,
418 running.as_ref(),
419 install.as_deref(),
420 &|status| set(status, false),
421 );
422 set(status, asked);
423 }
424 std::thread::park_timeout(Duration::from_secs(60 * 60));
425 }
426 })
427 .expect("The update thread starts")
428 .thread()
429 .clone()
430 };
431 Self {
432 shared,
433 thread,
434 restart: false,
435 }
436 }
437
438 pub fn status(&self) -> Status {
439 self.shared.lock().unwrap().status.clone()
440 }
441
442 pub fn automatic(&self) -> bool {
443 self.shared.lock().unwrap().automatic
444 }
445
446 pub fn set_automatic(&self, automatic: bool) {
447 self.shared.lock().unwrap().automatic = automatic;
448 self.thread.unpark();
449 }
450
451 /// Checks now, and shows what the check finds when it finishes.
452 pub fn check_now(&self) {
453 self.shared.lock().unwrap().asked = true;
454 self.thread.unpark();
455 }
456
457 /// The staged build to swap in, once Restart to Update has quit the app.
458 pub fn restarting(&self) -> Option<PathBuf> {
459 match self.status() {
460 Status::Ready(_, staged) if self.restart => Some(staged),
461 _ => None,
462 }
463 }
464}
465
466impl State {
467 /// Quits, and swaps the staged build in once the app has.
468 pub(crate) fn restart_to_update(&mut self) {
469 self.updates.restart = true;
470 let _ = self.proxy.send_event(UserEvent::Quit);
471 }
472
473 /// Shows what a check Check for Updates… asked for found, once it finishes.
474 pub(crate) fn updated(&mut self) {
475 let Some(status) = self.updates.shared.lock().unwrap().answer.take() else {
476 return;
477 };
478 match status {
479 Status::Ready(version, _) => {
480 if platform::confirm(
481 "Update ready",
482 &format!("Snowbound {version} is ready to install."),
483 "Later",
484 "Restart to Update",
485 ) {
486 self.restart_to_update();
487 }
488 }
489 Status::Available(version) => {
490 if platform::confirm(
491 "Update available",
492 &format!("Download Snowbound {version} from its build folder."),
493 "Later",
494 "Open Build Folder",
495 ) {
496 show_build(&version);
497 }
498 }
499 Status::UpToDate => platform::alert(
500 "Snowbound is up to date",
501 &format!("You have {}.", describe_running()),
502 ),
503 Status::Failed(reason) => platform::alert("Unable to check for updates", reason),
504 Status::Idle | Status::Downloading(_) => {}
505 }
506 }
507}
508
509/// Opens the folder a build is published in.
510pub fn show_build(version: &Version) {
511 platform::reveal(format!("{BASE}{}", version.folder()));
512}
513
514/// Starts this executable again to swap `staged` in once this process has quit, then open
515/// the app with the arguments this one had.
516pub fn relaunch(staged: &Path) -> std::io::Result<()> {
517 let install = install().ok_or_else(|| std::io::Error::other("Nothing to update"))?;
518 Command::new(std::env::current_exe()?)
519 .arg(FINISH)
520 .arg(std::process::id().to_string())
521 .arg(staged)
522 .arg(install)
523 .args(std::env::args_os().skip(1))
524 .spawn()
525 .map(drop)
526}
527
528/// The other half of `relaunch`, given the arguments after `FINISH`: waits up to a minute
529/// for the app to quit, swaps the update in and opens the app, updated or not.
530pub fn finish(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn std::error::Error>> {
531 let usage = "Usage: snowbound --finish-update PID STAGED INSTALL [ARGUMENT]...";
532 let pid: libc::pid_t = args.next().ok_or(usage)?.to_str().ok_or(usage)?.parse()?;
533 let staged = PathBuf::from(args.next().ok_or(usage)?);
534 let install = PathBuf::from(args.next().ok_or(usage)?);
535 let deadline = Instant::now() + Duration::from_secs(60);
536 // Signal 0 only asks whether the process is still there.
537 while unsafe { libc::kill(pid, 0) } == 0 {
538 if Instant::now() > deadline {
539 return Err("Snowbound didn’t quit, so the update wasn’t installed.".into());
540 }
541 std::thread::sleep(Duration::from_millis(100));
542 }
543 let applied = apply(&staged, &install);
544 let mut open = if cfg!(target_os = "macos") {
545 let mut open = Command::new("/usr/bin/open");
546 open.arg(&install);
547 let rest: Vec<OsString> = args.collect();
548 if !rest.is_empty() {
549 open.arg("--args").args(rest);
550 }
551 open
552 } else {
553 let mut open = Command::new(&install);
554 open.args(args);
555 open
556 };
557 open.spawn()?;
558 Ok(applied?)
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use ring::signature::{Ed25519KeyPair, KeyPair};
565
566 fn hex(bytes: &[u8]) -> String {
567 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
568 }
569
570 fn version(name: &str) -> Version {
571 Version::parse(name).unwrap()
572 }
573
574 fn generate() -> Ed25519KeyPair {
575 let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap();
576 Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap()
577 }
578
579 fn scratch(name: &str) -> PathBuf {
580 let folder =
581 std::env::temp_dir().join(format!("snowbound-update-{name}-{}", std::process::id()));
582 let _ = std::fs::remove_dir_all(&folder);
583 std::fs::create_dir_all(&folder).unwrap();
584 folder
585 }
586
587 #[test]
588 fn versions_parse_order_and_name_their_folders() {
589 let tenth = version("2026-09-29-r10");
590 assert_eq!(tenth.to_string(), "build 2026-09-29 revision 10");
591 assert_eq!(tenth.folder(), "2026-09-29.r10/");
592 assert_eq!(tenth.name(), "2026-09-29-r10");
593 assert!(version("2026-09-29-r9") < tenth);
594 assert!(tenth < version("2026-09-30-r1"));
595 assert!(version("2026-10-01-r1") > version("2026-09-30-r12"));
596 for bad in [
597 "2026-09-29",
598 "2026-09-29-r0",
599 "2026-9-29-r1",
600 "20260929-r1",
601 "2026-09-29-rx",
602 "abcd-09-29-r1",
603 ] {
604 assert_eq!(Version::parse(bad), None, "{bad}");
605 }
606 }
607
608 #[test]
609 fn latest_names_a_newer_build_for_this_platform_only() {
610 let latest = br#"{"macos-aarch64": "2026-09-29-r10", "linux-x86_64": "2026-09-28-r3"}"#;
611 let running = version("2026-09-29-r2");
612 assert_eq!(
613 newer(latest, "macos-aarch64", Some(&running)),
614 Ok(Some(version("2026-09-29-r10")))
615 );
616 assert_eq!(newer(latest, "linux-x86_64", Some(&running)), Ok(None));
617 assert_eq!(
618 newer(latest, "macos-aarch64", Some(&version("2026-09-29-r10"))),
619 Ok(None)
620 );
621 assert_eq!(newer(latest, "linux-aarch64", Some(&running)), Ok(None));
622 assert_eq!(
623 newer(latest, "linux-x86_64", None),
624 Ok(Some(version("2026-09-28-r3")))
625 );
626 assert!(newer(b"{\"macos-aarch64\": \"soon\"}", "macos-aarch64", None).is_err());
627 assert!(newer(b"<html>", "macos-aarch64", None).is_err());
628 }
629
630 /// A build.json for `archive`'s bytes under `platform`, with its signature.
631 fn publish(
632 pair: &Ed25519KeyPair,
633 name: &str,
634 platform: &str,
635 file: &str,
636 bytes: &[u8],
637 ) -> (Vec<u8>, Vec<u8>) {
638 let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
639 let build = serde_json::to_vec_pretty(&serde_json::json!({
640 "version": name,
641 "commit": "0123456789abcdef",
642 "archives": {platform: {
643 "file": file,
644 "size": bytes.len(),
645 "sha256": hex(digest.as_ref()),
646 "signature": hex(pair.sign(bytes).as_ref()),
647 }},
648 }))
649 .unwrap();
650 let signature = hex(pair.sign(&build).as_ref()).into_bytes();
651 (build, signature)
652 }
653
654 #[test]
655 fn signatures_and_hashes_are_checked() {
656 let pair = generate();
657 let key = pair.public_key().as_ref();
658 let tenth = version("2026-09-29-r10");
659 let bytes = b"an archive".to_vec();
660 let (build, signature) =
661 publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes);
662 let found = archive(key, &build, &signature, &tenth, "linux-x86_64").unwrap();
663 check_archive(key, &found, &bytes).unwrap();
664
665 let mut tampered = build.clone();
666 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();
667 tampered[at] = b'b';
668 assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64").is_err());
669 assert!(
670 archive(
671 generate().public_key().as_ref(),
672 &build,
673 &signature,
674 &tenth,
675 "linux-x86_64"
676 )
677 .is_err()
678 );
679 assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64").is_err());
680 assert!(
681 archive(
682 key,
683 &build,
684 &signature,
685 &version("2026-09-29-r11"),
686 "linux-x86_64"
687 )
688 .is_err()
689 );
690 assert!(archive(key, &build, &signature, &tenth, "macos-aarch64").is_err());
691
692 assert!(
693 check_archive(key, &found, b"an archivf")
694 .unwrap_err()
695 .contains("SHA-256")
696 );
697 assert!(
698 check_archive(key, &found, b"an archive!")
699 .unwrap_err()
700 .contains("bytes")
701 );
702 // Right size and hash, but signed by another key.
703 let forged = Archive {
704 signature: hex(generate().sign(&bytes).as_ref()),
705 ..found
706 };
707 assert!(
708 check_archive(key, &forged, &bytes)
709 .unwrap_err()
710 .contains("signature")
711 );
712 }
713
714 /// An archive as `tools/release.py` packs this platform's, holding `marker`.
715 fn pack(folder: &Path, marker: &str) -> Vec<u8> {
716 if !cfg!(target_os = "macos") {
717 return marker.as_bytes().to_vec();
718 }
719 let source = folder.join("source");
720 let _ = std::fs::remove_dir_all(&source);
721 let bundle = staged(&source);
722 let file = bundle.join("Contents/MacOS/Snowbound");
723 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
724 std::fs::write(&file, marker).unwrap();
725 let archive = folder.join("archive.zip");
726 let status = Command::new("/usr/bin/ditto")
727 .args(["-c", "-k", "--keepParent"])
728 .arg(&bundle)
729 .arg(&archive)
730 .status();
731 assert!(status.unwrap().success());
732 std::fs::read(archive).unwrap()
733 }
734
735 /// Reads the marker an install's executable holds.
736 fn marker(install: &Path) -> String {
737 let file = if cfg!(target_os = "macos") {
738 install.join("Contents/MacOS/Snowbound")
739 } else {
740 install.to_owned()
741 };
742 std::fs::read_to_string(file).unwrap()
743 }
744
745 /// A check against a published folder finds, verifies and stages the newer build, a second
746 /// check reuses the staged one, and applying it replaces the install.
747 #[test]
748 fn a_newer_build_is_staged_and_swapped_in() {
749 let folder = scratch("stage");
750 let platform = platform();
751 let pair = generate();
752 let key = pair.public_key().as_ref().to_vec();
753 let published = folder.join("published");
754 let build = published.join("2026-09-29.r10");
755 std::fs::create_dir_all(&build).unwrap();
756 let bytes = pack(&folder, "new");
757 let (manifest, signature) =
758 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);
759 std::fs::write(build.join("app.archive"), &bytes).unwrap();
760 std::fs::write(build.join("build.json"), manifest).unwrap();
761 std::fs::write(build.join("build.json.sig"), signature).unwrap();
762 std::fs::write(
763 published.join("latest.json"),
764 serde_json::to_vec(&serde_json::json!({ &platform: "2026-09-29-r10" })).unwrap(),
765 )
766 .unwrap();
767 let fetched = Mutex::new(Vec::new());
768 let fetch = |path: &str, limit: u64| {
769 fetched.lock().unwrap().push(path.to_owned());
770 let bytes = std::fs::read(published.join(path)).map_err(|error| error.to_string())?;
771 if bytes.len() as u64 > limit {
772 return Err("too big".to_owned());
773 }
774 Ok(bytes)
775 };
776
777 let apps = folder.join("Applications");
778 std::fs::create_dir_all(&apps).unwrap();
779 let old = pack(&folder, "old");
780 let installed = stage(&old, &folder.join("unpacked"), &version("2026-09-28-r1")).unwrap();
781 let install = apps.join(installed.file_name().unwrap());
782 std::fs::rename(installed, &install).unwrap();
783 assert_eq!(marker(&install), "old");
784
785 let running = version("2026-09-28-r1");
786 let progress = Mutex::new(Vec::new());
787 let status = check(&fetch, &key, Some(&running), Some(&install), &|status| {
788 progress.lock().unwrap().push(status)
789 });
790 let Status::Ready(found, item) = status else {
791 panic!("{status:?}");
792 };
793 assert_eq!(found, version("2026-09-29-r10"));
794 assert_eq!(
795 *progress.lock().unwrap(),
796 [Status::Downloading(found.clone())]
797 );
798 assert!(item.starts_with(staging(&install).unwrap()));
799 assert_eq!(marker(&install), "old");
800
801 fetched.lock().unwrap().clear();
802 let again = check(&fetch, &key, Some(&running), Some(&install), &|_| {});
803 assert_eq!(again, Status::Ready(found.clone(), item.clone()));
804 assert!(
805 !fetched
806 .lock()
807 .unwrap()
808 .iter()
809 .any(|path| path.ends_with("app.archive"))
810 );
811
812 assert_eq!(
813 check(&fetch, &key, Some(&found), Some(&install), &|_| {}),
814 Status::UpToDate
815 );
816 assert_eq!(
817 check(&fetch, &key, Some(&running), None, &|_| {}),
818 Status::Available(found.clone())
819 );
820 assert_eq!(
821 check(
822 &fetch,
823 generate().public_key().as_ref(),
824 Some(&running),
825 Some(&install),
826 &|_| {}
827 ),
828 Status::Failed(UNVERIFIED)
829 );
830 assert_eq!(
831 check(
832 &|_, _| Err("offline".into()),
833 &key,
834 Some(&running),
835 Some(&install),
836 &|_| {}
837 ),
838 Status::Failed(UNREACHABLE)
839 );
840
841 apply(&item, &install).unwrap();
842 assert_eq!(marker(&install), "new");
843 assert!(!staging(&install).unwrap().exists());
844 assert_eq!(std::fs::read_dir(&apps).unwrap().count(), 1);
845 std::fs::remove_dir_all(&folder).unwrap();
846 }
847
848 #[test]
849 fn a_tampered_archive_is_not_staged() {
850 let folder = scratch("tampered");
851 let platform = platform();
852 let pair = generate();
853 let bytes = pack(&folder, "new");
854 let (manifest, signature) =
855 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);
856 let mut served = bytes.clone();
857 let last = served.len() - 1;
858 served[last] ^= 1;
859 let fetch = |path: &str, _| {
860 Ok(match path {
861 "latest.json" => {
862 serde_json::to_vec(&serde_json::json!({ &platform: "2026-09-29-r10" })).unwrap()
863 }
864 "2026-09-29.r10/build.json" => manifest.clone(),
865 "2026-09-29.r10/build.json.sig" => signature.clone(),
866 _ => served.clone(),
867 })
868 };
869 let install = folder.join("Snowbound.app");
870 let status = check(
871 &fetch,
872 pair.public_key().as_ref(),
873 None,
874 Some(&install),
875 &|_| {},
876 );
877 assert_eq!(status, Status::Failed(UNVERIFIED));
878 assert!(!staging(&install).unwrap().exists());
879 std::fs::remove_dir_all(&folder).unwrap();
880 }
881
882 /// The newest published build for this platform, checked from an older build over the
883 /// network with the release key, stages and swaps into a scratch install.
884 #[test]
885 #[ignore = "reads the published builds"]
886 fn the_published_build_installs() {
887 let folder = scratch("published");
888 let install = folder.join(if cfg!(target_os = "macos") {
889 "Snowbound.app"
890 } else {
891 "snowbound"
892 });
893 let old = version("2000-01-01-r1");
894 let status = check(
895 &download,
896 &unhex(KEY).unwrap(),
897 Some(&old),
898 Some(&install),
899 &|_| {},
900 );
901 let Status::Ready(found, staged) = status else {
902 panic!("{status:?}");
903 };
904 apply(&staged, &install).unwrap();
905 // 10.6's builds are unsigned.
906 if cfg!(target_os = "macos") && cfg!(feature = "wgpu") {
907 let verified = Command::new("codesign")
908 .args(["--verify", "--strict"])
909 .arg(&install)
910 .status();
911 assert!(verified.unwrap().success());
912 }
913 eprintln!("Installed {found} into {}", install.display());
914 std::fs::remove_dir_all(&folder).unwrap();
915 }
916}
crates/snowbound/src/watch.rs+14-1
...@@ -7,6 +7,9 @@ use std::path::{Path, PathBuf};...@@ -7,6 +7,9 @@ use std::path::{Path, PathBuf};
7/// Reports until dropped.7/// Reports until dropped.
8pub struct Watch {8pub struct Watch {
9 _stream: platform::Stream,9 _stream: platform::Stream,
10 /// In iCloud Drive, file coordination's reports too, which name the conflict versions a
11 /// file gains; those change no file.
12 _presenter: Option<crate::icloud::Presenter>,
10}13}
1114
12/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to15/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to
...@@ -35,6 +38,13 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)...@@ -35,6 +38,13 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)
35 } else {38 } else {
36 return None;39 return None;
37 };40 };
41 let changed = std::sync::Arc::new(changed);
42 let presenter = crate::icloud::ubiquitous(&root)
43 .then(|| {
44 let changed = std::sync::Arc::clone(&changed);
45 crate::icloud::presenter(&root, move |paths| changed(paths))
46 })
47 .flatten();
38 let folder = root.clone();48 let folder = root.clone();
39 let relative = move |paths: Vec<PathBuf>| {49 let relative = move |paths: Vec<PathBuf>| {
40 changed(50 changed(
...@@ -50,7 +60,10 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)...@@ -50,7 +60,10 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)
50 platform::Stream::start(&folders, Box::new(relative))60 platform::Stream::start(&folders, Box::new(relative))
51 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))61 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))
52 .ok()62 .ok()
53 .map(|stream| Watch { _stream: stream })63 .map(|stream| Watch {
64 _stream: stream,
65 _presenter: presenter,
66 })
54}67}
5568
56/// Whether the folder at `path` is on this computer's own disks rather than on a network69/// Whether the folder at `path` is on this computer's own disks rather than on a network
crates/snowbound/tests/replay.rs created+124
...@@ -0,0 +1,124 @@
1//! The app driven by a replay in a hidden window, its accessibility tree written as text.
2#![cfg(target_os = "macos")]
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// A scratch folder, deleted when dropped.
8struct Scratch(PathBuf);
9
10impl Scratch {
11 fn new(name: &str) -> Self {
12 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
13 let _ = std::fs::remove_dir_all(&path);
14 std::fs::create_dir_all(path.join("notebook")).unwrap();
15 Self(path)
16 }
17}
18
19impl Drop for Scratch {
20 fn drop(&mut self) {
21 let _ = std::fs::remove_dir_all(&self.0);
22 }
23}
24
25/// Runs `steps` against a copy of `notebook`'s files, then returns the trees each
26/// `accessibility` step named by the trailing word wrote.
27fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {
28 let dir = &scratch.0;
29 if let Some(source) = notebook {
30 for entry in std::fs::read_dir(source).unwrap() {
31 let entry = entry.unwrap();
32 std::fs::copy(entry.path(), dir.join("notebook").join(entry.file_name())).unwrap();
33 }
34 }
35 let mut script = String::from("wait 500\n");
36 let mut trees = Vec::new();
37 for step in steps {
38 match step.strip_prefix("accessibility ") {
39 Some(name) => {
40 let path = dir.join(format!("{name}.txt"));
41 script += &format!("accessibility {}\nwait 100\n", path.display());
42 trees.push(path);
43 }
44 None => script += &format!("{step}\n"),
45 }
46 }
47 script += "quit\n";
48 std::fs::write(dir.join("script"), script).unwrap();
49 std::fs::write(
50 dir.join("settings.json"),
51 r#"{"user_name": "Snowbound Test"}"#,
52 )
53 .unwrap();
54 let status = Command::new(env!("CARGO_BIN_EXE_snowbound"))
55 .env("SNOWBOUND_REPLAY", dir.join("script"))
56 .arg("--notebook")
57 .arg(dir.join("notebook"))
58 .args(["--cache".as_ref(), dir.join("cache").as_os_str()])
59 .args(["--settings".as_ref(), dir.join("settings.json").as_os_str()])
60 .args(["--screenshot".as_ref(), dir.join("shot").as_os_str()])
61 .status()
62 .unwrap();
63 assert!(status.success(), "the replay ended with {status}");
64 trees
65 .iter()
66 .map(|path| std::fs::read_to_string(path).unwrap())
67 .collect()
68}
69
70/// The names of the toolbar's controls in `tree`, one a line.
71fn toolbar_names(tree: &str) -> Vec<&str> {
72 tree.lines()
73 .skip_while(|line| line.trim() != "Toolbar")
74 .skip(1)
75 .take_while(|line| line.starts_with(" "))
76 .filter(|line| !line.starts_with(" "))
77 .map(|line| line.split('"').nth(1).unwrap_or_default())
78 .collect()
79}
80
81fn assert_named_once(tree: &str) {
82 let names = toolbar_names(tree);
83 assert!(names.len() > 10, "{tree}");
84 for (at, name) in names.iter().enumerate() {
85 assert!(
86 !name.is_empty() && !names[at + 1..].contains(name),
87 "{name:?} is not the only toolbar control so named:\n{tree}"
88 );
89 }
90}
91
92#[test]
93fn a_notebook_without_sections_has_a_tree_without_a_page() {
94 let scratch = Scratch::new("no-sections");
95 let [tree] = replay(&scratch, None, &["accessibility tree"])
96 .try_into()
97 .unwrap();
98 assert!(
99 tree.contains(r#"Label = "No sections in this notebook""#),
100 "{tree}"
101 );
102 assert!(
103 tree.contains(r#"ComboBox "Font" = "#) && tree.contains("[disabled]"),
104 "{tree}"
105 );
106 assert_named_once(&tree);
107}
108
109#[test]
110fn each_toolbar_control_has_a_name_of_its_own_at_every_width() {
111 let scratch = Scratch::new("toolbar-names");
112 let notebook =
113 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
114 let mut steps = Vec::new();
115 for width in ["1600", "1180", "900", "600"] {
116 steps.push(format!("resize {width} 760"));
117 steps.push("wait 300".into());
118 steps.push(format!("accessibility {width}"));
119 }
120 let steps: Vec<_> = steps.iter().map(String::as_str).collect();
121 for tree in replay(&scratch, Some(&notebook), &steps) {
122 assert_named_once(&tree);
123 }
124}
crates/ui/Cargo.toml+2
...@@ -5,12 +5,14 @@ edition = "2024"...@@ -5,12 +5,14 @@ edition = "2024"
5publish = false5publish = false
66
7[dependencies]7[dependencies]
8accesskit = "0.25.0"
8draw = { path = "../draw", default-features = false, features = ["render"] }9draw = { path = "../draw", default-features = false, features = ["render"] }
9nucleo-matcher = "0.3.1"10nucleo-matcher = "0.3.1"
10parley.workspace = true11parley.workspace = true
11winit = "0.30.13"12winit = "0.30.13"
1213
13[dev-dependencies]14[dev-dependencies]
15accesskit_consumer = "0.39.0"
14draw = { path = "../draw", features = ["wgpu"] }16draw = { path = "../draw", features = ["wgpu"] }
15pollster = "0.4"17pollster = "0.4"
16png = "0.18"18png = "0.18"
crates/ui/examples/popups.rs+18-9
...@@ -502,14 +502,23 @@ impl Scene {...@@ -502,14 +502,23 @@ impl Scene {
502 },502 },
503 );503 );
504 let combo = ui.id("font");504 let combo = ui.id("font");
505 shell::combo(ui, "font", "Calibri", 120.0);505 shell::combo(
506 ui,
507 "font",
508 "Font",
509 "Calibri",
510 120.0,
511 Id::ROOT.child("fonts"),
512 true,
513 );
506 let split = ui.id("color");514 let split = ui.id("color");
507 shell::split_button(515 shell::split_button(
508 ui,516 ui,
509 "color",517 "color",
518 "Font Color",
510 art!("icons/font-color"),519 art!("icons/font-color"),
511 Some(draw::srgb(0xe8, 0x3a, 0x30)),520 Some(draw::srgb(0xe8, 0x3a, 0x30)),
512 false,521 None,
513 Id::ROOT.child("font color"),522 Id::ROOT.child("font color"),
514 );523 );
515 ui.close();524 ui.close();
...@@ -581,7 +590,7 @@ fn menu(scene: &mut Scene) {...@@ -581,7 +590,7 @@ fn menu(scene: &mut Scene) {
581 text: "Bold",590 text: "Bold",
582 icon: Some(art!("icons/bold")),591 icon: Some(art!("icons/bold")),
583 shortcut: "⌘B",592 shortcut: "⌘B",
584 checked: true,593 checked: Some(true),
585 separated: true,594 separated: true,
586 ..Item::default()595 ..Item::default()
587 },596 },
...@@ -637,7 +646,7 @@ fn font_items() -> Vec<Item<'static>> {...@@ -637,7 +646,7 @@ fn font_items() -> Vec<Item<'static>> {
637 .iter()646 .iter()
638 .map(|text| Item {647 .map(|text| Item {
639 text,648 text,
640 checked: *text == "Calibri",649 checked: Some(*text == "Calibri"),
641 ..Item::default()650 ..Item::default()
642 })651 })
643 .collect()652 .collect()
...@@ -652,7 +661,7 @@ fn fonts(scene: &mut Scene) {...@@ -652,7 +661,7 @@ fn fonts(scene: &mut Scene) {
652 });661 });
653}662}
654663
655fn office_colors() -> Vec<[f32; 4]> {664fn office_colors() -> Vec<([f32; 4], &'static str)> {
656 let theme = [665 let theme = [
657 (0xff, 0xff, 0xff),666 (0xff, 0xff, 0xff),
658 (0x00, 0x00, 0x00),667 (0x00, 0x00, 0x00),
...@@ -691,7 +700,7 @@ fn office_colors() -> Vec<[f32; 4]> {...@@ -691,7 +700,7 @@ fn office_colors() -> Vec<[f32; 4]> {
691 }));700 }));
692 }701 }
693 colors.extend(standard);702 colors.extend(standard);
694 colors703 colors.into_iter().map(|color| (color, "")).collect()
695}704}
696705
697fn colors(scene: &mut Scene) {706fn colors(scene: &mut Scene) {
...@@ -731,7 +740,7 @@ fn highlight(scene: &mut Scene) {...@@ -731,7 +740,7 @@ fn highlight(scene: &mut Scene) {
731 (0xc0, 0xc0, 0xc0),740 (0xc0, 0xc0, 0xc0),
732 (0x00, 0x00, 0x00),741 (0x00, 0x00, 0x00),
733 ]742 ]
734 .map(|(r, g, b)| draw::srgb(r, g, b));743 .map(|(r, g, b)| (draw::srgb(r, g, b), ""));
735 let id = Id::ROOT.child("highlight");744 let id = Id::ROOT.child("highlight");
736 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {745 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {
737 let anchor = below(ui, split);746 let anchor = below(ui, split);
...@@ -761,7 +770,7 @@ fn palette(scene: &mut Scene) {...@@ -761,7 +770,7 @@ fn palette(scene: &mut Scene) {
761 });770 });
762 let id = Id::ROOT.child("palette");771 let id = Id::ROOT.child("palette");
763 scene.open(id, typed("ne"), |ui, _| {772 scene.open(id, typed("ne"), |ui, _| {
764 ui::popup::palette(ui, id, &commands, "Search commands");773 ui::popup::palette(ui, id, &[("", &commands)], "Search commands");
765 });774 });
766}775}
767776
...@@ -806,7 +815,7 @@ fn tags(scene: &mut Scene) {...@@ -806,7 +815,7 @@ fn tags(scene: &mut Scene) {
806 .map(|(text, icon, shortcut)| Item {815 .map(|(text, icon, shortcut)| Item {
807 text,816 text,
808 icon,817 icon,
809 colored: true,818 tint: Some([1.0; 4]),
810 shortcut,819 shortcut,
811 ..Item::default()820 ..Item::default()
812 });821 });
crates/ui/src/access.rs created+499
...@@ -0,0 +1,499 @@
1//! The interface as assistive technology sees it: an AccessKit tree of the latest frame's
2//! boxes that have a role, and the keyboard's way among its controls, as `arc/ui.md`
3//! describes them.
4
5use crate::{Axis, Event, Flags, Id, Ui};
6use accesskit::{
7 Action, ActionData, ActionRequest, Node, NodeId, Orientation, Rect, Role, TreeId, TreeInfo,
8 TreeUpdate,
9};
10use std::collections::{HashMap, HashSet};
11use winit::{event::Ime, keyboard::NamedKey};
12
13/// A control the keyboard can focus.
14pub(crate) struct Stop {
15 id: Id,
16 rect: [f32; 4],
17 /// The toolbar or tab list it moves within by arrows, with its role and their axis.
18 group: Option<(Id, Role, Axis)>,
19 popup: Option<Id>,
20 selected: bool,
21 /// Takes keys of its own: a text field, or a box the host handles.
22 typing: bool,
23 custom: bool,
24}
25
26impl Stop {
27 /// What Tab steps over as one: its group, or itself.
28 fn step(&self) -> Id {
29 self.group.map_or(self.id, |(group, ..)| group)
30 }
31}
32
33/// Roles that hold controls rather than being one, and whose text is their own.
34pub(crate) fn holds(role: Role) -> bool {
35 matches!(
36 role,
37 Role::GenericContainer
38 | Role::Window
39 | Role::Group
40 | Role::Toolbar
41 | Role::Dialog
42 | Role::AlertDialog
43 | Role::Menu
44 | Role::MenuBar
45 | Role::ListBox
46 | Role::List
47 | Role::TabList
48 | Role::Tree
49 | Role::Grid
50 | Role::Pane
51 | Role::ScrollView
52 | Role::RadioGroup
53 | Role::Form
54 | Role::Region
55 )
56}
57
58/// Roles whose text is their value rather than their name.
59fn valued(role: Role) -> bool {
60 matches!(
61 role,
62 Role::TextInput
63 | Role::SearchInput
64 | Role::MultilineTextInput
65 | Role::ComboBox
66 | Role::EditableComboBox
67 | Role::SpinButton
68 )
69}
70
71/// The axis a group's arrows move along, where it is one Tab steps over whole.
72fn composite(node: &Node) -> Option<Axis> {
73 let along = match node.role() {
74 Role::Toolbar | Role::TabList => Axis::X,
75 Role::Tree | Role::RadioGroup => Axis::Y,
76 _ => return None,
77 };
78 Some(match node.orientation() {
79 Some(Orientation::Horizontal) => Axis::X,
80 Some(Orientation::Vertical) => Axis::Y,
81 None => along,
82 })
83}
84
85/// Lists and menus move their own selection by the keys of the box that owns them.
86fn navigates_itself(role: Role) -> bool {
87 matches!(role, Role::Menu | Role::ListBox)
88}
89
90/// Whether a box shows: not folded away, nor a clip shut to nothing, as a closed panel is.
91fn shown(built: &crate::Built) -> bool {
92 let [left, top, right, bottom] = built.rect;
93 !built.hidden && !(built.flags.contains(Flags::CLIP) && (left >= right || top >= bottom))
94}
95
96fn bounds(rect: [f32; 4], scale: f64) -> Rect {
97 let [x0, y0, x1, y1] = rect.map(|side| f64::from(side) * scale);
98 Rect::new(x0, y0, x1, y1)
99}
100
101impl Ui {
102 /// Box `id` as built this frame, as assistive technology sees it, a generic container
103 /// where it has no `Spec::role`. Bounds, children and the actions its flags allow are
104 /// filled in; where a control has no label, its text and its children's name it.
105 pub fn access(&mut self, id: Id) -> Option<&mut Node> {
106 let index = self.nodes.iter().rposition(|node| node.id == id)?;
107 Some(
108 self.nodes[index]
109 .access
110 .get_or_insert_with(|| Node::new(Role::GenericContainer)),
111 )
112 }
113
114 /// The changes to the interface's tree since the last update, with the window as its root
115 /// named `label`; `scale` is the window's pixels per logical pixel. Call after `end`.
116 pub fn accessibility(&mut self, label: &str, scale: f64) -> TreeUpdate {
117 let (nodes, focus) = self.tree_nodes(label, scale);
118 let fresh = self.sent.is_empty();
119 let mut changed = Vec::new();
120 let mut sent = HashMap::with_capacity(nodes.len());
121 for (id, node) in nodes {
122 if self.sent.get(&id) != Some(&node) {
123 changed.push((id.node(), node.clone()));
124 }
125 sent.insert(id, node);
126 }
127 self.sent = sent;
128 TreeUpdate {
129 nodes: changed,
130 tree: fresh.then(|| TreeInfo::new(Id::ROOT.node())),
131 tree_id: TreeId::ROOT,
132 focus: focus.node(),
133 }
134 }
135
136 /// The interface's whole tree, as `accessibility` first sends it, leaving what it last
137 /// sent alone.
138 pub fn accessibility_tree(&self, label: &str, scale: f64) -> TreeUpdate {
139 let (nodes, focus) = self.tree_nodes(label, scale);
140 TreeUpdate {
141 nodes: nodes
142 .into_iter()
143 .map(|(id, node)| (id.node(), node))
144 .collect(),
145 tree: Some(TreeInfo::new(Id::ROOT.node())),
146 tree_id: TreeId::ROOT,
147 focus: focus.node(),
148 }
149 }
150
151 /// Every node of the tree, and the focused one.
152 fn tree_nodes(&self, label: &str, scale: f64) -> (Vec<(Id, Node)>, Id) {
153 let mut nodes = Vec::new();
154 let mut children = Vec::new();
155 let mut seen = HashSet::from([Id::ROOT]);
156 self.describe(0, scale, None, &mut children, &mut nodes, &mut seen);
157 let mut root = Node::new(Role::Window);
158 root.set_label(label);
159 root.set_bounds(bounds(self.nodes[0].rect, scale));
160 root.set_children(children.into_iter().map(|(id, _)| id).collect::<Vec<_>>());
161 nodes.push((Id::ROOT, root));
162 let focus = self.focus.filter(|focus| seen.contains(focus));
163 (nodes, focus.unwrap_or(Id::ROOT))
164 }
165
166 /// Forgets what was sent, so the next update sends the whole tree.
167 pub fn deactivate_accessibility(&mut self) {
168 self.sent.clear();
169 }
170
171 /// Adds the nodes of box `index`'s children to `out` and their ids and rectangles to
172 /// `children`. Inside a control, text without a role goes to `texts` to name it.
173 fn describe(
174 &self,
175 index: usize,
176 scale: f64,
177 mut texts: Option<&mut Vec<String>>,
178 children: &mut Vec<(NodeId, [f32; 4])>,
179 out: &mut Vec<(Id, Node)>,
180 seen: &mut HashSet<Id>,
181 ) {
182 let tooltip = self.tip.map(|tip| tip.id.child("tooltip"));
183 for &child in &self.nodes[index].children {
184 let built = &self.nodes[child];
185 // A box built twice in a frame would give its node two parents.
186 if !shown(built) || Some(built.id) == tooltip || !seen.insert(built.id) {
187 continue;
188 }
189 let text = built
190 .label
191 .as_ref()
192 .map(|label| label.key.0.as_str())
193 .filter(|text| !text.is_empty());
194 let Some(access) = &built.access else {
195 match (text, texts.as_deref_mut()) {
196 (Some(text), Some(texts)) => texts.push(text.to_owned()),
197 (Some(text), None) => {
198 let mut node = Node::new(Role::Label);
199 node.set_value(text);
200 node.set_bounds(bounds(built.rect, scale));
201 children.push((built.id.node(), built.rect));
202 out.push((built.id, node));
203 }
204 (None, _) => {}
205 }
206 self.describe(child, scale, texts.as_deref_mut(), children, out, seen);
207 continue;
208 };
209 let mut node = access.clone();
210 let role = node.role();
211 node.set_bounds(bounds(built.rect, scale));
212 children.push((built.id.node(), built.rect));
213 if node.tree_id().is_some() {
214 // A graft holds only the tree it names, so what is built inside it shows
215 // beside it.
216 out.push((built.id, node));
217 self.describe(child, scale, texts.as_deref_mut(), children, out, seen);
218 continue;
219 }
220 let control = !holds(role);
221 let mut own = Vec::new();
222 let mut inner = Vec::new();
223 self.describe(
224 child,
225 scale,
226 control.then_some(&mut own),
227 &mut inner,
228 out,
229 seen,
230 );
231 if node.label().is_none() && !valued(role) {
232 let name: Vec<_> = text
233 .map(str::to_owned)
234 .into_iter()
235 .chain(own)
236 .collect::<Vec<_>>();
237 if !name.is_empty() {
238 node.set_label(name.join(" "));
239 }
240 }
241 if let Some(axis) = composite(&node) {
242 // Built in paint order, as the open tab over the rest, but read in place.
243 let along = usize::from(axis == Axis::Y);
244 inner.sort_by(|a, b| a.1[along].total_cmp(&b.1[along]));
245 }
246 node.set_children(inner.into_iter().map(|(id, _)| id).collect::<Vec<_>>());
247 if control && !node.is_disabled() {
248 // A text field takes the focus where the pointer would click it.
249 if built.flags.contains(Flags::FOCUSABLE) {
250 node.add_action(Action::Focus);
251 if valued(role) {
252 node.add_action(Action::SetValue);
253 }
254 } else if built.flags.contains(Flags::CLICKABLE) {
255 node.add_action(Action::Click);
256 node.add_action(Action::Focus);
257 }
258 }
259 out.push((built.id, node));
260 }
261 }
262
263 /// The controls the keyboard can focus, in the order built.
264 pub(crate) fn stops(&self) -> Vec<Stop> {
265 let mut stops = Vec::new();
266 self.collect_stops(0, None, None, &mut stops);
267 stops
268 }
269
270 fn collect_stops(
271 &self,
272 index: usize,
273 group: Option<(Id, Role, Axis)>,
274 popup: Option<Id>,
275 stops: &mut Vec<Stop>,
276 ) {
277 for &child in &self.nodes[index].children {
278 let built = &self.nodes[child];
279 if !shown(built) {
280 continue;
281 }
282 let popup = if built.anchor.is_some() {
283 Some(built.id)
284 } else {
285 popup
286 };
287 let mut group = group;
288 if let Some(access) = &built.access {
289 let role = access.role();
290 if navigates_itself(role) {
291 continue;
292 }
293 if let Some(axis) = composite(access) {
294 group = Some((built.id, role, axis));
295 } else if built.flags.intersects(Flags::CLICKABLE | Flags::FOCUSABLE)
296 && !access.is_disabled()
297 && (!holds(role) || built.flags.contains(Flags::CUSTOM))
298 {
299 stops.push(Stop {
300 id: built.id,
301 rect: built.rect,
302 group,
303 popup,
304 selected: access.is_selected() == Some(true),
305 typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM),
306 custom: built.flags.contains(Flags::CUSTOM),
307 });
308 }
309 }
310 self.collect_stops(child, group, popup, stops);
311 }
312 }
313
314 /// Moves the focus for a key among the controls, returning whether it took the key.
315 pub(crate) fn traverse(&mut self, key: NamedKey) -> bool {
316 let back = self.modifiers.shift_key();
317 let scope = self.popups.last().map(|popup| popup.id);
318 let at = self
319 .focus
320 .and_then(|focus| self.stops.iter().position(|stop| stop.id == focus));
321 // The focused control, where it takes no keys of its own.
322 let control = at.filter(|at| !self.stops[*at].typing);
323 let target = match key {
324 NamedKey::Tab => {
325 let from = match (self.focus, at) {
326 (None, _) => None,
327 (focus, _) if focus == scope => None,
328 (_, Some(at)) if !self.stops[at].custom => Some(at),
329 _ => return false,
330 };
331 self.step(scope, from, back, |_| true)
332 }
333 NamedKey::F6 if scope.is_none() => {
334 self.step(None, at, back, |stop| stop.group.is_some() || stop.custom)
335 }
336 NamedKey::F5
337 if self.modifiers.control_key()
338 && draw::edit::Platform::CURRENT == draw::edit::Platform::MacOs
339 && scope.is_none() =>
340 {
341 self.entry(|stop| matches!(stop.group, Some((_, Role::Toolbar, _))))
342 }
343 NamedKey::ArrowLeft
344 | NamedKey::ArrowRight
345 | NamedKey::ArrowUp
346 | NamedKey::ArrowDown
347 | NamedKey::Home
348 | NamedKey::End => {
349 let Some(at) = control else {
350 return false;
351 };
352 let Some((group, _, axis)) = self.stops[at].group else {
353 return false;
354 };
355 let along = usize::from(axis == Axis::Y);
356 let mut members: Vec<_> = self
357 .stops
358 .iter()
359 .filter(|stop| stop.group.is_some_and(|(id, ..)| id == group))
360 .collect();
361 members.sort_by(|a, b| a.rect[along].total_cmp(&b.rect[along]));
362 let place = members.iter().position(|stop| stop.id == self.stops[at].id);
363 let (Some(place), count) = (place, members.len()) else {
364 return false;
365 };
366 let [before, after] = match axis {
367 Axis::X => [NamedKey::ArrowLeft, NamedKey::ArrowRight],
368 Axis::Y => [NamedKey::ArrowUp, NamedKey::ArrowDown],
369 };
370 let to = match key {
371 NamedKey::Home => 0,
372 NamedKey::End => count - 1,
373 key if key == before => (place + count - 1) % count,
374 key if key == after => (place + 1) % count,
375 _ => return false,
376 };
377 Some(members[to].id)
378 }
379 NamedKey::Space | NamedKey::Enter => {
380 let Some(at) = control else {
381 return false;
382 };
383 self.press(self.stops[at].id);
384 return true;
385 }
386 NamedKey::Escape if scope.is_none() && control.is_some() => {
387 self.focus = self.resume.take();
388 self.focus_ring = false;
389 return true;
390 }
391 _ => return false,
392 };
393 let Some(target) = target else {
394 return false;
395 };
396 if at.is_none_or(|at| self.stops[at].custom) {
397 self.resume = self.focus;
398 }
399 self.focus = Some(target);
400 self.focus_ring = true;
401 true
402 }
403
404 /// The control Tab or F6 steps to in popup `scope` from stop `from`, over the steps
405 /// `counts` keeps: a group's is its selected control, or its first.
406 fn step(
407 &self,
408 scope: Option<Id>,
409 from: Option<usize>,
410 back: bool,
411 counts: impl Fn(&Stop) -> bool,
412 ) -> Option<Id> {
413 let mut entries: Vec<usize> = Vec::new();
414 for (index, stop) in self.stops.iter().enumerate() {
415 if stop.popup != scope {
416 continue;
417 }
418 match entries.last_mut() {
419 Some(last) if self.stops[*last].step() == stop.step() => {
420 if stop.selected {
421 *last = index;
422 }
423 }
424 _ => entries.push(index),
425 }
426 }
427 entries.retain(|entry| counts(&self.stops[*entry]));
428 let count = entries.len();
429 if count == 0 {
430 return None;
431 }
432 let here = from.and_then(|from| {
433 let step = self.stops[from].step();
434 entries
435 .iter()
436 .position(|entry| self.stops[*entry].step() == step)
437 });
438 let to = match (here, from) {
439 (Some(here), _) if back => (here + count - 1) % count,
440 (Some(here), _) => (here + 1) % count,
441 // From a control not counted, the step either side of it.
442 (None, Some(from)) => {
443 let after = entries
444 .iter()
445 .position(|entry| *entry > from)
446 .unwrap_or(count);
447 (if back { after + count - 1 } else { after }) % count
448 }
449 (None, None) if back => count - 1,
450 (None, None) => 0,
451 };
452 Some(self.stops[entries[to]].id)
453 }
454
455 /// The control a group `matching` is entered at.
456 fn entry(&self, matching: impl Fn(&Stop) -> bool) -> Option<Id> {
457 let first = self.stops.iter().position(&matching)?;
458 let group = self.stops[first].step();
459 let selected = self
460 .stops
461 .iter()
462 .find(|stop| stop.step() == group && stop.selected);
463 Some(selected.unwrap_or(&self.stops[first]).id)
464 }
465
466 /// Presses and clicks box `id` in one, as a key or assistive technology does.
467 fn press(&mut self, id: Id) {
468 let signal = self.signals.entry(id).or_default();
469 signal.pressed = true;
470 signal.clicked = true;
471 }
472
473 /// Carries out assistive technology's `request`.
474 pub(crate) fn act(&mut self, request: ActionRequest) {
475 let id = Id(request.target_node.0);
476 match (request.action, request.data) {
477 (Action::Click, _) => {
478 // As a press there would, one on a box beneath the popups closes them.
479 if self.hits[..self.modal].iter().any(|hit| hit.id == id) {
480 self.close_from(0);
481 }
482 self.press(id);
483 }
484 (Action::Focus, _) => self.focus = Some(id),
485 (Action::SetValue, Some(ActionData::Value(value))) => {
486 self.focus_all(id);
487 self.signals
488 .entry(id)
489 .or_default()
490 .events
491 .push(Event::Ime(Ime::Commit(value.into())));
492 }
493 _ => {}
494 }
495 }
496}
497
498#[cfg(test)]
499mod tests;
crates/ui/src/access/tests.rs created+750
...@@ -0,0 +1,750 @@
1use crate::{shell, *};
2use accesskit::{Action, ActionRequest, NodeId, Role, TreeId};
3use accesskit_consumer::{NodeRef, Tree, common_filter};
4use std::time::Duration;
5use winit::keyboard::NamedKey;
6
7thread_local! {
8 static START: Instant = Instant::now();
9 static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
10}
11
12fn new_ui() -> Ui {
13 Ui::new(Theme::dark(), Duration::from_millis(500))
14}
15
16/// Builds a frame 16 ms after this thread's previous one.
17fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) {
18 let frames = FRAMES.with(|frames| {
19 frames.set(frames.get() + 1);
20 frames.get()
21 });
22 let now = START.with(|start| *start) + Duration::from_millis(16) * frames;
23 ui.begin([600.0, 300.0], 2.0, now);
24 build(ui);
25 ui.end();
26}
27
28/// The tree as the platform sees it, generic containers left out: a line per node of its
29/// role, name, value, states, shortcut and actions, with the focus marked.
30fn snapshot(tree: &Tree) -> String {
31 fn write(node: &NodeRef, depth: usize, out: &mut String) {
32 let data = node.data();
33 let mut line = format!("{}{:?}", " ".repeat(depth), node.role());
34 if let Some(label) = data.label() {
35 line += &format!(" {label:?}");
36 }
37 if let Some(value) = data.value() {
38 line += &format!(" = {value:?}");
39 }
40 if let Some(placeholder) = data.placeholder() {
41 line += &format!(" ({placeholder})");
42 }
43 for (state, on) in [
44 ("toggled", data.toggled() == Some(accesskit::Toggled::True)),
45 (
46 "untoggled",
47 data.toggled() == Some(accesskit::Toggled::False),
48 ),
49 ("expanded", data.is_expanded() == Some(true)),
50 ("collapsed", data.is_expanded() == Some(false)),
51 ("selected", data.is_selected() == Some(true)),
52 ("disabled", data.is_disabled()),
53 ("focused", node.is_focused()),
54 ] {
55 if on {
56 line += &format!(" [{state}]");
57 }
58 }
59 if let Some(keys) = data.keyboard_shortcut() {
60 line += &format!(" <{keys}>");
61 }
62 if let Some(description) = data.description() {
63 line += &format!(" -- {description}");
64 }
65 let actions: Vec<_> = [
66 (Action::Click, "click"),
67 (Action::Focus, "focus"),
68 (Action::SetValue, "set"),
69 ]
70 .into_iter()
71 .filter(|(action, _)| data.supports_action(*action))
72 .map(|(_, name)| name)
73 .collect();
74 if !actions.is_empty() {
75 line += &format!(" {{{}}}", actions.join(" "));
76 }
77 out.push_str(&line);
78 out.push('\n');
79 for child in node.filtered_children(common_filter) {
80 write(&child, depth + 1, out);
81 }
82 }
83 let mut out = String::new();
84 write(&tree.state().root(), 0, &mut out);
85 out
86}
87
88/// The tree built from scratch, as assistive technology first asks for it.
89fn tree(ui: &mut Ui) -> Tree {
90 ui.deactivate_accessibility();
91 Tree::new(ui.accessibility("Notes", 2.0), true)
92}
93
94fn key(ui: &mut Ui, named: NamedKey) {
95 ui.event(Event::Key {
96 key: winit::keyboard::Key::Named(named),
97 text: None,
98 });
99}
100
101fn toolbar_id() -> Id {
102 Id::ROOT.child("toolbar")
103}
104
105fn font_menu() -> Id {
106 Id::ROOT.child("font menu")
107}
108
109fn more_menu() -> Id {
110 Id::ROOT.child("more menu")
111}
112
113/// A toolbar of the kit's controls, as the app builds its own; returns what was clicked.
114fn toolbar(ui: &mut Ui, bold: bool) -> Vec<&'static str> {
115 let mut clicked = Vec::new();
116 let text = ui.theme.text;
117 ui.open_as(
118 toolbar_id(),
119 Spec {
120 size: [fill(), px(30.0)],
121 gap: 4.0,
122 role: Some(Role::Toolbar),
123 ..Spec::default()
124 },
125 );
126 if shell::tool_button(ui, "undo", shell::CHEVRON, text, None).clicked {
127 clicked.push("undo");
128 }
129 popup::tooltip(ui, "Undo", "⌘Z", None);
130 shell::unavailable(ui, "cut", shell::CHEVRON, text, false);
131 popup::tooltip(ui, "Cut", "⌘X", None);
132 if shell::tool_button(ui, "bold", shell::CHEVRON, text, Some(bold)).clicked {
133 clicked.push("bold");
134 }
135 popup::tooltip(ui, "Bold", "⌘B", Some("Makes the selected text bold."));
136 if shell::split_button(
137 ui,
138 "paste",
139 "Paste",
140 shell::CHEVRON,
141 None,
142 None,
143 more_menu(),
144 )
145 .clicked
146 {
147 clicked.push("paste");
148 }
149 popup::tooltip(ui, "Paste", "⌘V", None);
150 let combo = ui.id("font");
151 shell::combo(ui, "font", "Font", "Calibri", 120.0, font_menu(), true);
152 let level = ui.open(
153 "level",
154 Spec {
155 flags: Flags::CLICKABLE,
156 size: [px(44.0), px(22.0)],
157 text: Some("100%"),
158 role: Some(Role::Button),
159 ..Spec::default()
160 },
161 );
162 ui.close();
163 popup::tooltip(ui, "Actual Size", "", None);
164 if let Some(node) = ui.access(level) {
165 node.set_value("100%");
166 }
167 ui.close();
168 let items = [
169 popup::Item {
170 text: "Recent",
171 heading: true,
172 ..popup::Item::default()
173 },
174 popup::Item {
175 text: "Calibri",
176 checked: Some(true),
177 current: true,
178 ..popup::Item::default()
179 },
180 popup::Item {
181 text: "Arial",
182 ..popup::Item::default()
183 },
184 popup::Item {
185 text: "Wingdings",
186 disabled: true,
187 ..popup::Item::default()
188 },
189 ];
190 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
191 popup::menu(ui, font_menu(), anchor, &items, Some("Font"));
192 let paste = [popup::Item {
193 text: "Keep Text Only",
194 shortcut: "⌘⇧V",
195 ..popup::Item::default()
196 }];
197 let anchor = Anchor::Below(ui.rect(ui.id("paste")).unwrap_or_default());
198 popup::menu(ui, more_menu(), anchor, &paste, None);
199 clicked
200}
201
202#[test]
203fn the_toolbar_names_its_controls_from_their_tooltips() {
204 let mut ui = new_ui();
205 for _ in 0..2 {
206 frame(&mut ui, |ui| {
207 toolbar(ui, true);
208 });
209 }
210 assert_eq!(
211 snapshot(&tree(&mut ui)),
212 r#"Window "Notes" [focused]
213 Toolbar
214 Button "Undo" <⌘Z> {click focus}
215 Button "Cut" [disabled] <⌘X>
216 Button "Bold" [toggled] <⌘B> -- Makes the selected text bold. {click focus}
217 Button "Paste" <⌘V> {click focus}
218 Button "Paste Options" [collapsed] {click focus}
219 ComboBox "Font" = "Calibri" [collapsed] {click focus}
220 Button "Actual Size" = "100%" {click focus}
221"#
222 );
223}
224
225#[test]
226fn each_toolbar_control_has_a_name_of_its_own() {
227 let mut ui = new_ui();
228 for _ in 0..2 {
229 frame(&mut ui, |ui| {
230 toolbar(ui, true);
231 });
232 }
233 let tree = tree(&mut ui);
234 let toolbar = tree
235 .state()
236 .node_by_tree_local_id(toolbar_id().node(), TreeId::ROOT)
237 .unwrap();
238 let names: Vec<_> = toolbar
239 .filtered_children(common_filter)
240 .map(|node| node.label().unwrap_or_default())
241 .collect();
242 for (at, name) in names.iter().enumerate() {
243 assert!(
244 !name.is_empty() && !names[at + 1..].contains(name),
245 "{names:?}"
246 );
247 }
248}
249
250#[test]
251fn a_disabled_combo_shows_its_value_but_takes_no_clicks_or_focus() {
252 let mut ui = new_ui();
253 let build = |ui: &mut Ui| {
254 frame(ui, |ui| {
255 shell::combo(ui, "font", "Font", "Calibri", 120.0, font_menu(), false);
256 });
257 };
258 build(&mut ui);
259 let [left, top, right, bottom] = ui.rect(ui.id("font")).unwrap();
260 let middle = [(left + right) / 2.0, (top + bottom) / 2.0];
261 ui.event(Event::PointerMoved(middle));
262 for pressed in [true, false] {
263 ui.event(Event::Button {
264 button: winit::event::MouseButton::Left,
265 pressed,
266 at: Instant::now(),
267 });
268 build(&mut ui);
269 }
270 assert!(!ui.popup_open(font_menu()));
271 key(&mut ui, NamedKey::Tab);
272 build(&mut ui);
273 assert_ne!(ui.focused(), Some(ui.id("font")));
274 assert!(
275 snapshot(&tree(&mut ui)).contains(r#"ComboBox "Font" = "Calibri" [collapsed] [disabled]"#),
276 "{}",
277 snapshot(&tree(&mut ui))
278 );
279}
280
281#[test]
282fn a_toggle_reads_as_one_while_off_and_a_plain_button_as_none() {
283 let mut ui = new_ui();
284 for _ in 0..2 {
285 frame(&mut ui, |ui| {
286 toolbar(ui, false);
287 });
288 }
289 let snapshot = snapshot(&tree(&mut ui));
290 assert!(
291 snapshot.contains(r#"Button "Bold" [untoggled] <⌘B>"#),
292 "{snapshot}"
293 );
294 assert!(
295 snapshot.contains(r#"Button "Undo" <⌘Z> {click focus}"#),
296 "{snapshot}"
297 );
298}
299
300#[test]
301fn a_row_opening_a_menu_says_so_instead_of_naming_keys() {
302 let mut ui = new_ui();
303 let menu = Id::ROOT.child("menu");
304 let items = [
305 popup::Item {
306 text: "Bullet Library",
307 submenu: true,
308 ..popup::Item::default()
309 },
310 popup::Item {
311 text: "Bold",
312 shortcut: "⌘B",
313 checked: Some(false),
314 ..popup::Item::default()
315 },
316 ];
317 ui.open_popup(menu);
318 for _ in 0..2 {
319 frame(&mut ui, |ui| {
320 popup::menu(ui, menu, Anchor::Below([0.0; 4]), &items, None);
321 });
322 }
323 let tree = tree(&mut ui);
324 let snapshot = snapshot(&tree);
325 assert!(
326 snapshot.contains(r#"MenuItem "Bullet Library" [collapsed]"#),
327 "{snapshot}"
328 );
329 assert!(!snapshot.contains('›'), "{snapshot}");
330 assert!(
331 snapshot.contains(r#"MenuItemCheckBox "Bold" [untoggled]"#),
332 "{snapshot}"
333 );
334 let update = ui.accessibility_tree("Notes", 2.0);
335 let (_, row) = update
336 .nodes
337 .iter()
338 .find(|(_, node)| node.label() == Some("Bullet Library"))
339 .expect("the row");
340 assert_eq!(row.has_popup(), Some(accesskit::HasPopup::Menu));
341}
342
343#[test]
344fn swatches_are_named_by_their_colour_names_or_hex() {
345 let mut ui = new_ui();
346 let grid = Id::ROOT.child("colours");
347 let swatches = [([1.0, 1.0, 0.0, 1.0], "Yellow"), ([0.0, 0.0, 0.0, 1.0], "")];
348 ui.open_popup(grid);
349 for _ in 0..2 {
350 frame(&mut ui, |ui| {
351 popup::colors(ui, grid, Anchor::Below([0.0; 4]), "No Color", &swatches, 2);
352 });
353 }
354 let snapshot = snapshot(&tree(&mut ui));
355 assert!(snapshot.contains(r#"MenuItem "Yellow""#), "{snapshot}");
356 assert!(snapshot.contains(r##"MenuItem "#000000""##), "{snapshot}");
357}
358
359#[test]
360fn an_open_menu_takes_the_focus_and_its_highlight_is_the_focused_item() {
361 let mut ui = new_ui();
362 frame(&mut ui, |ui| {
363 toolbar(ui, false);
364 });
365 // Tab enters the toolbar, and the arrows reach the font box, which Space opens.
366 key(&mut ui, NamedKey::Tab);
367 for _ in 0..4 {
368 key(&mut ui, NamedKey::ArrowRight);
369 }
370 frame(&mut ui, |ui| {
371 toolbar(ui, false);
372 });
373 assert_eq!(ui.focused(), Some(toolbar_id().child("font")));
374 key(&mut ui, NamedKey::Space);
375 for _ in 0..3 {
376 frame(&mut ui, |ui| {
377 toolbar(ui, false);
378 });
379 }
380 assert!(ui.popup_open(font_menu()));
381 let snapshot = snapshot(&tree(&mut ui));
382 assert!(
383 snapshot.contains(r#"ComboBox "Font" = "Calibri" [expanded]"#),
384 "{snapshot}"
385 );
386 assert!(
387 snapshot.ends_with(
388 r#" Menu
389 SearchInput = "" (Font) {focus set}
390 Heading "Recent"
391 MenuItemCheckBox "Calibri" [toggled] [selected] [focused] {click focus}
392 MenuItem "Arial" {click focus}
393 MenuItem "Wingdings" [disabled]
394"#
395 ),
396 "the checked item starts highlighted, and is the focus within the filter:\n{snapshot}"
397 );
398 key(&mut ui, NamedKey::ArrowDown);
399 frame(&mut ui, |ui| {
400 toolbar(ui, false);
401 });
402 let snapshot = self::snapshot(&tree(&mut ui));
403 assert!(
404 snapshot.contains(r#"MenuItem "Arial" [selected] [focused]"#),
405 "{snapshot}"
406 );
407 // Escape closes it, and the focus returns to the box it opened from.
408 key(&mut ui, NamedKey::Escape);
409 frame(&mut ui, |ui| {
410 toolbar(ui, false);
411 });
412 assert!(!ui.popup_open(font_menu()));
413 assert_eq!(ui.focused(), Some(toolbar_id().child("font")));
414 let snapshot = self::snapshot(&tree(&mut ui));
415 assert!(
416 snapshot.contains(r#"ComboBox "Font" = "Calibri" [collapsed] [focused]"#),
417 "{snapshot}"
418 );
419}
420
421#[test]
422fn keys_step_through_the_toolbar_press_its_buttons_and_return() {
423 let mut ui = new_ui();
424 let page = Id::ROOT.child("page");
425 let build = |ui: &mut Ui| {
426 let clicked = toolbar(ui, false);
427 ui.leaf(
428 "page",
429 Spec {
430 flags: Flags::CUSTOM | Flags::FOCUSABLE,
431 size: [fill(), fill()],
432 role: Some(Role::GenericContainer),
433 ..Spec::default()
434 },
435 );
436 clicked
437 };
438 frame(&mut ui, |ui| {
439 build(ui);
440 });
441 ui.set_focus(Some(page));
442 // The page takes Tab for itself; F6 moves to the toolbar, entered at its first button.
443 key(&mut ui, NamedKey::Tab);
444 frame(&mut ui, |ui| {
445 build(ui);
446 });
447 assert_eq!(ui.focused(), Some(page));
448 key(&mut ui, NamedKey::F6);
449 frame(&mut ui, |ui| {
450 build(ui);
451 });
452 assert_eq!(ui.focused(), Some(toolbar_id().child("undo")));
453 // The disabled button is passed over, and the arrows wrap.
454 key(&mut ui, NamedKey::ArrowRight);
455 frame(&mut ui, |ui| {
456 build(ui);
457 });
458 assert_eq!(ui.focused(), Some(toolbar_id().child("bold")));
459 key(&mut ui, NamedKey::ArrowLeft);
460 key(&mut ui, NamedKey::ArrowLeft);
461 frame(&mut ui, |ui| {
462 build(ui);
463 });
464 assert_eq!(ui.focused(), Some(toolbar_id().child("level")));
465 key(&mut ui, NamedKey::Home);
466 key(&mut ui, NamedKey::ArrowRight);
467 key(&mut ui, NamedKey::Enter);
468 let mut clicked = Vec::new();
469 frame(&mut ui, |ui| clicked = build(ui));
470 assert_eq!(clicked, ["bold"]);
471 // Tab leaves the toolbar as one step, for the page.
472 key(&mut ui, NamedKey::Tab);
473 frame(&mut ui, |ui| {
474 build(ui);
475 });
476 assert_eq!(ui.focused(), Some(page));
477 key(&mut ui, NamedKey::F6);
478 key(&mut ui, NamedKey::Escape);
479 frame(&mut ui, |ui| {
480 build(ui);
481 });
482 assert_eq!(ui.focused(), Some(page), "Escape returns to the page");
483}
484
485fn dialog_id() -> Id {
486 Id::ROOT.child("dialog")
487}
488
489/// A dialog of a labelled field, a check box and two buttons; returns the buttons clicked.
490fn dialog(ui: &mut Ui, name: &mut String, checked: bool) -> Vec<&'static str> {
491 let mut clicked = Vec::new();
492 if !ui.popup_open(dialog_id()) {
493 return clicked;
494 }
495 ui.open_as(
496 dialog_id(),
497 Spec {
498 axis: Axis::Y,
499 size: [px(300.0), children()],
500 anchor: Some(Anchor::Dialog),
501 role: Some(Role::Dialog),
502 ..Spec::default()
503 },
504 );
505 if let Some(node) = ui.access(dialog_id()) {
506 node.set_label("Rename");
507 }
508 ui.leaf(
509 "heading",
510 Spec {
511 size: [fit(), px(20.0)],
512 text: Some("Choose a name for the section."),
513 ..Spec::default()
514 },
515 );
516 let field = dialog_id().child("name");
517 text_field(
518 ui,
519 field,
520 name,
521 "Untitled",
522 Spec {
523 size: [fill(), px(22.0)],
524 ..Spec::default()
525 },
526 );
527 if let Some(node) = ui.access(field) {
528 node.set_label("Name");
529 }
530 if check_box(ui, "keep", "Keep a copy", checked).clicked {
531 clicked.push("keep");
532 }
533 for label in ["OK", "Cancel"] {
534 if button(ui, label, label).clicked {
535 clicked.push(label);
536 }
537 }
538 ui.close();
539 clicked
540}
541
542#[test]
543fn a_dialog_holds_the_focus_and_tab_steps_through_its_controls() {
544 let mut ui = new_ui();
545 let mut name = "Notes".to_owned();
546 let build = |ui: &mut Ui, name: &mut String| {
547 ui.leaf(
548 "behind",
549 Spec {
550 flags: Flags::CLICKABLE,
551 size: [px(40.0), px(20.0)],
552 text: Some("Behind"),
553 role: Some(Role::Button),
554 ..Spec::default()
555 },
556 );
557 dialog(ui, name, true)
558 };
559 frame(&mut ui, |ui| {
560 build(ui, &mut name);
561 });
562 ui.open_popup(dialog_id());
563 frame(&mut ui, |ui| {
564 build(ui, &mut name);
565 });
566 assert_eq!(
567 snapshot(&tree(&mut ui)),
568 r#"Window "Notes"
569 Button "Behind" {click focus}
570 Dialog "Rename" [focused]
571 Label = "Choose a name for the section."
572 TextInput "Name" = "Notes" (Untitled) {focus set}
573 CheckBox "Keep a copy" [toggled] {click focus}
574 Button "OK" {click focus}
575 Button "Cancel" {click focus}
576"#
577 );
578 let field = dialog_id().child("name");
579 let mut focused = Vec::new();
580 for _ in 0..5 {
581 key(&mut ui, NamedKey::Tab);
582 frame(&mut ui, |ui| {
583 build(ui, &mut name);
584 });
585 focused.push(ui.focused());
586 }
587 let [keep, ok, cancel] = ["keep", "OK", "Cancel"].map(|part| Some(dialog_id().child(part)));
588 assert_eq!(
589 focused,
590 [Some(field), keep, ok, cancel, Some(field)],
591 "Tab stays within the dialog"
592 );
593 ui.event(Event::Modifiers(ModifiersState::SHIFT));
594 key(&mut ui, NamedKey::Tab);
595 ui.event(Event::Modifiers(ModifiersState::empty()));
596 key(&mut ui, NamedKey::Space);
597 let mut clicked = Vec::new();
598 frame(&mut ui, |ui| clicked = build(ui, &mut name));
599 assert_eq!(clicked, ["Cancel"]);
600}
601
602#[test]
603fn assistive_technology_presses_focuses_and_sets_values() {
604 let mut ui = new_ui();
605 let mut name = "Notes".to_owned();
606 frame(&mut ui, |ui| {
607 dialog(ui, &mut name, false);
608 });
609 ui.open_popup(dialog_id());
610 frame(&mut ui, |ui| {
611 dialog(ui, &mut name, false);
612 });
613 let request = |action, id: Id, data| {
614 Event::Access(ActionRequest {
615 action,
616 target_tree: TreeId::ROOT,
617 target_node: id.node(),
618 data,
619 })
620 };
621 let field = dialog_id().child("name");
622 ui.event(request(
623 Action::SetValue,
624 field,
625 Some(accesskit::ActionData::Value("Journal".into())),
626 ));
627 ui.event(request(Action::Click, dialog_id().child("OK"), None));
628 let mut clicked = Vec::new();
629 frame(&mut ui, |ui| clicked = dialog(ui, &mut name, false));
630 assert_eq!(name, "Journal");
631 assert_eq!(clicked, ["OK"]);
632 assert_eq!(ui.focused(), Some(field));
633 ui.event(request(Action::Focus, dialog_id().child("keep"), None));
634 frame(&mut ui, |ui| {
635 dialog(ui, &mut name, false);
636 });
637 assert_eq!(ui.focused(), Some(dialog_id().child("keep")));
638}
639
640#[test]
641fn the_palette_is_a_dialog_whose_field_leads_through_its_options() {
642 let mut ui = new_ui();
643 let id = Id::ROOT.child("palette");
644 let items = [
645 popup::Item {
646 text: "Bold",
647 shortcut: "⌘B",
648 ..popup::Item::default()
649 },
650 popup::Item {
651 text: "New Page",
652 ..popup::Item::default()
653 },
654 popup::Item {
655 text: "Italic",
656 ..popup::Item::default()
657 },
658 ];
659 frame(&mut ui, |ui| {
660 popup::palette(ui, id, &[("", &items)], "Search commands");
661 });
662 ui.open_popup(id);
663 frame(&mut ui, |ui| {
664 popup::palette(ui, id, &[("", &items)], "Search commands");
665 });
666 for text in ["i", "t"] {
667 ui.event(Event::Key {
668 key: winit::keyboard::Key::Character(text.into()),
669 text: Some(text.into()),
670 });
671 frame(&mut ui, |ui| {
672 popup::palette(ui, id, &[("", &items)], "Search commands");
673 });
674 }
675 frame(&mut ui, |ui| {
676 popup::palette(ui, id, &[("", &items)], "Search commands");
677 });
678 assert_eq!(
679 snapshot(&tree(&mut ui)),
680 r#"Window "Notes"
681 Dialog "Search commands"
682 SearchInput = "it" (Search commands) {focus set}
683 ListBox
684 ListBoxOption "Italic" [selected] [focused] {click focus}
685"#
686 );
687}
688
689#[test]
690fn updates_send_only_what_changed_and_grafts_hold_the_host_s_tree() {
691 let mut ui = new_ui();
692 let page = Id::ROOT.child("page");
693 let tree_id = TreeId(accesskit::Uuid::from_u128(7));
694 let build = |ui: &mut Ui, bold: bool| {
695 toolbar(ui, bold);
696 ui.open_as(
697 page,
698 Spec {
699 flags: Flags::CUSTOM | Flags::FOCUSABLE,
700 size: [fill(), fill()],
701 role: Some(Role::GenericContainer),
702 ..Spec::default()
703 },
704 );
705 if let Some(node) = ui.access(page) {
706 node.set_tree_id(tree_id);
707 }
708 // Built inside the page, the scrollbar shows beside it.
709 button(ui, "scroll", "Scroll");
710 ui.close();
711 };
712 frame(&mut ui, |ui| build(ui, false));
713 let mut tree = tree(&mut ui);
714 let mut page_node = accesskit::Node::new(Role::Group);
715 page_node.set_label("Page");
716 tree.update_and_process_changes(
717 accesskit::TreeUpdate {
718 nodes: vec![(NodeId(0), page_node)],
719 tree: Some(accesskit::TreeInfo::new(NodeId(0))),
720 tree_id,
721 focus: NodeId(0),
722 },
723 &mut NoChanges,
724 );
725 ui.set_focus(Some(page));
726 frame(&mut ui, |ui| build(ui, true));
727 let update = ui.accessibility("Notes", 2.0);
728 let changed: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect();
729 assert_eq!(changed, [toolbar_id().child("bold").node()]);
730 assert_eq!(update.focus, page.node());
731 tree.update_and_process_changes(update, &mut NoChanges);
732 let snapshot = snapshot(&tree);
733 assert!(
734 snapshot.ends_with(
735 r#" Group "Page" [focused]
736 Button "Scroll" {click focus}
737"#
738 ),
739 "{snapshot}"
740 );
741}
742
743struct NoChanges;
744
745impl accesskit_consumer::TreeChangeHandler for NoChanges {
746 fn node_added(&mut self, _: &NodeRef) {}
747 fn node_updated(&mut self, _: &NodeRef, _: &NodeRef) {}
748 fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {}
749 fn node_removed(&mut self, _: &NodeRef) {}
750}
crates/ui/src/lib.rs+131-25
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3//! animation state. Input is answered with the previous frame's layout, so a frame's3//! animation state. Input is answered with the previous frame's layout, so a frame's
4//! events are routed before building and its layout is solved after.4//! events are routed before building and its layout is solved after.
55
6mod access;
6mod layout;7mod layout;
7mod list;8mod list;
8pub mod popup;9pub mod popup;
...@@ -15,7 +16,7 @@ pub use list::{List, Row, Rows, list};...@@ -15,7 +16,7 @@ pub use list::{List, Row, Rows, list};
15pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};16pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
16pub use widgets::{17pub use widgets::{
17 PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key,18 PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key,
18 edit_modifiers, scrollbar, text_field,19 edit_modifiers, password_field, scrollbar, text_field,
19};20};
2021
21use draw::{22use draw::{
...@@ -55,6 +56,8 @@ const POPUP: [f32; 2] = [0.16, 0.12];...@@ -55,6 +56,8 @@ const POPUP: [f32; 2] = [0.16, 0.12];
55const TIP_DELAY: f32 = 0.5;56const TIP_DELAY: f32 = 0.5;
56const TIP_WARM: f32 = 0.5;57const TIP_WARM: f32 = 0.5;
57const TIP_FADE: f32 = 0.1;58const TIP_FADE: f32 = 0.1;
59/// How far outside a control the ring the keyboard's focus draws round it lies, and its width.
60const FOCUS_RING: f32 = 2.0;
5861
59/// A box's identity across frames: its parent's id combined with a builder-chosen part.62/// A box's identity across frames: its parent's id combined with a builder-chosen part.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
...@@ -69,6 +72,11 @@ impl Id {...@@ -69,6 +72,11 @@ impl Id {
69 part.hash(&mut hasher);72 part.hash(&mut hasher);
70 Id(hasher.finish())73 Id(hasher.finish())
71 }74 }
75
76 /// The box's node in the interface's accessibility tree.
77 pub fn node(self) -> accesskit::NodeId {
78 accesskit::NodeId(self.0)
79 }
72}80}
7381
74#[derive(Clone, Copy, Debug, PartialEq)]82#[derive(Clone, Copy, Debug, PartialEq)]
...@@ -210,10 +218,11 @@ pub enum Anchor {...@@ -210,10 +218,11 @@ pub enum Anchor {
210 Over([f32; 4]),218 Over([f32; 4]),
211 /// At a point, as a context menu opens.219 /// At a point, as a context menu opens.
212 Point([f32; 2]),220 Point([f32; 2]),
213 /// Centred in the window over the interface, which dims, as a dialog opens.221 /// Centred across the window near its top, over the interface, which dims, as a dialog
222 /// opens.
214 Dialog,223 Dialog,
215 /// Centred across the window near its top, swinging in as a dialog does, as a command224 /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a
216 /// palette opens.225 /// command palette opens.
217 Top,226 Top,
218}227}
219228
...@@ -227,9 +236,8 @@ impl Anchor {...@@ -227,9 +236,8 @@ impl Anchor {
227 Anchor::Right(rect) => (rect, Some(0)),236 Anchor::Right(rect) => (rect, Some(0)),
228 Anchor::Over(rect) => (rect, None),237 Anchor::Over(rect) => (rect, None),
229 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),238 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
230 Anchor::Dialog => return ((room - size) / 2.0).max(0.0),239 Anchor::Dialog | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0),
231 Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0),240 Anchor::Dialog | Anchor::Top => return (room / 8.0).min(room - size).max(0.0),
232 Anchor::Top => return room / 8.0,
233 };241 };
234 let [low, high] = [rect[axis], rect[axis + 2]];242 let [low, high] = [rect[axis], rect[axis + 2]];
235 let (first, second) = if along == Some(axis) {243 let (first, second) = if along == Some(axis) {
...@@ -363,12 +371,17 @@ pub struct Spec<'a> {...@@ -363,12 +371,17 @@ pub struct Spec<'a> {
363 /// How far the children fade out into the box's fill towards its leading and trailing371 /// How far the children fade out into the box's fill towards its leading and trailing
364 /// edges, as a row of them cut there does.372 /// edges, as a row of them cut there does.
365 pub fade: [f32; 2],373 pub fade: [f32; 2],
374 /// What the box is to assistive technology; without one, its text shows as a label and
375 /// its children as its parent's. `Ui::access` adds the rest of what it says.
376 pub role: Option<accesskit::Role>,
366}377}
367378
368/// Input the host forwards; positions and wheel distances are logical pixels.379/// Input the host forwards; positions and wheel distances are logical pixels.
369#[derive(Clone, Debug, PartialEq)]380#[derive(Clone, Debug, PartialEq)]
370pub enum Event {381pub enum Event {
371 PointerMoved([f32; 2]),382 PointerMoved([f32; 2]),
383 /// A pen's pressure from 0 to 1 for the pointer events that follow; none for a mouse.
384 Pressure(Option<f32>),
372 PointerLeft,385 PointerLeft,
373 Button {386 Button {
374 button: MouseButton,387 button: MouseButton,
...@@ -382,6 +395,8 @@ pub enum Event {...@@ -382,6 +395,8 @@ pub enum Event {
382 },395 },
383 Ime(Ime),396 Ime(Ime),
384 Modifiers(ModifiersState),397 Modifiers(ModifiersState),
398 /// Assistive technology acting on a box of the tree `Ui::accessibility` built.
399 Access(accesskit::ActionRequest),
385}400}
386401
387/// How the user acted on a box this frame.402/// How the user acted on a box this frame.
...@@ -501,6 +516,7 @@ struct Built {...@@ -501,6 +516,7 @@ struct Built {
501 marks: Vec<([f32; 4], [f32; 4], f32)>,516 marks: Vec<([f32; 4], [f32; 4], f32)>,
502 fold: Option<u32>,517 fold: Option<u32>,
503 fade: [f32; 2],518 fade: [f32; 2],
519 access: Option<accesskit::Node>,
504 /// Folded away with the form it belongs to: it takes no room, paint or input, and takes520 /// Folded away with the form it belongs to: it takes no room, paint or input, and takes
505 /// its group's rectangle.521 /// its group's rectangle.
506 hidden: bool,522 hidden: bool,
...@@ -524,8 +540,8 @@ struct State {...@@ -524,8 +540,8 @@ struct State {
524 /// A text field's selection, and the selection and unit of the press a drag extends.540 /// A text field's selection, and the selection and unit of the press a drag extends.
525 selection: Selection,541 selection: Selection,
526 press: (Selection, SelectionUnit),542 press: (Selection, SelectionUnit),
527 /// The field selects all its text when next built.543 /// The text a field selects when next built, from and to byte offsets clamped to its end.
528 select_all: bool,544 select: Option<[usize; 2]>,
529 /// Where a dragged scrollbar thumb was taken, from its start.545 /// Where a dragged scrollbar thumb was taken, from its start.
530 grab: f32,546 grab: f32,
531 /// A system scroller's part held down, and when a held arrow or track next repeats.547 /// A system scroller's part held down, and when a held arrow or track next repeats.
...@@ -544,9 +560,11 @@ struct Popup {...@@ -544,9 +560,11 @@ struct Popup {
544 highlight: Option<u64>,560 highlight: Option<u64>,
545 /// A colour picker's hue in degrees, saturation and lightness, once it has one.561 /// A colour picker's hue in degrees, saturation and lightness, once it has one.
546 picked: Option<[f32; 3]>,562 picked: Option<[f32; 3]>,
547 /// The height its results ease from and to, and the seconds since they set out.
548 height: Option<[f32; 3]>,
549 opened: Instant,563 opened: Instant,
564 /// As a submenu, the row of the menu below it that it opens beside, whatever its anchor.
565 beside: Option<Id>,
566 /// A menu's row whose submenu opens, and when; None once it has.
567 submenu: Option<(u64, Option<Instant>)>,
550}568}
551569
552/// The tooltip of the box under the pointer, the last frame it was built, and when it570/// The tooltip of the box under the pointer, the last frame it was built, and when it
...@@ -684,6 +702,14 @@ pub struct Ui {...@@ -684,6 +702,14 @@ pub struct Ui {
684 caret: Option<(Id, Instant, u64)>,702 caret: Option<(Id, Instant, u64)>,
685 /// When the next timed change is due, such as a caret's blink.703 /// When the next timed change is due, such as a caret's blink.
686 wake: Option<Instant>,704 wake: Option<Instant>,
705 /// The controls the keyboard moves among, in the latest layout's order.
706 stops: Vec<access::Stop>,
707 /// The keyboard last moved the focus, so the focused control shows a ring.
708 focus_ring: bool,
709 /// Where Escape returns the focus from the controls the keyboard moved it among.
710 resume: Option<Id>,
711 /// The nodes last sent to assistive technology.
712 sent: HashMap<Id, accesskit::Node>,
687}713}
688714
689impl Ui {715impl Ui {
...@@ -726,6 +752,10 @@ impl Ui {...@@ -726,6 +752,10 @@ impl Ui {
726 routed: false,752 routed: false,
727 caret: None,753 caret: None,
728 wake: None,754 wake: None,
755 stops: Vec::new(),
756 focus_ring: false,
757 resume: None,
758 sent: HashMap::new(),
729 }759 }
730 }760 }
731761
...@@ -767,6 +797,14 @@ impl Ui {...@@ -767,6 +797,14 @@ impl Ui {
767 self.focus797 self.focus
768 }798 }
769799
800 /// The focus where it is a text field, which the keyboard's edits act on.
801 pub fn focused_field(&self) -> Option<Id> {
802 self.focus.filter(|focus| {
803 let flags = self.hit_flags(*focus);
804 flags.contains(Flags::FOCUSABLE) && !flags.contains(Flags::CUSTOM)
805 })
806 }
807
770 pub fn set_focus(&mut self, id: Option<Id>) {808 pub fn set_focus(&mut self, id: Option<Id>) {
771 self.focus = id;809 self.focus = id;
772 }810 }
...@@ -776,7 +814,7 @@ impl Ui {...@@ -776,7 +814,7 @@ impl Ui {
776 pub fn focus_all(&mut self, id: Id) {814 pub fn focus_all(&mut self, id: Id) {
777 let state = self.states.entry(id).or_default();815 let state = self.states.entry(id).or_default();
778 state.touched = self.frame;816 state.touched = self.frame;
779 state.select_all = true;817 state.select = Some([0, usize::MAX]);
780 self.focus = Some(id);818 self.focus = Some(id);
781 }819 }
782820
...@@ -833,6 +871,18 @@ impl Ui {...@@ -833,6 +871,18 @@ impl Ui {
833 for event in std::mem::take(&mut self.queue) {871 for event in std::mem::take(&mut self.queue) {
834 self.route(event);872 self.route(event);
835 }873 }
874 // Boxes that move under a still pointer, as reordered tabs or a scrolled list, take
875 // the hover from it.
876 if !self.moved
877 && self.active.is_none()
878 && let Some(point) = self.pointer
879 {
880 let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
881 if hover != self.hover {
882 self.custom_event(self.hover, Event::PointerLeft);
883 self.hover = hover;
884 }
885 }
836 self.ease(dt);886 self.ease(dt);
837 }887 }
838888
...@@ -856,6 +906,7 @@ impl Ui {...@@ -856,6 +906,7 @@ impl Ui {
856 self.hover = hover;906 self.hover = hover;
857 self.custom_event(self.active.or(hover), event);907 self.custom_event(self.active.or(hover), event);
858 }908 }
909 Event::Pressure(_) => self.custom_event(self.active.or(self.hover), event),
859 Event::PointerLeft => {910 Event::PointerLeft => {
860 self.pointer = None;911 self.pointer = None;
861 if self.active.is_none() {912 if self.active.is_none() {
...@@ -871,6 +922,7 @@ impl Ui {...@@ -871,6 +922,7 @@ impl Ui {
871 let Some(point) = self.pointer else {922 let Some(point) = self.pointer else {
872 return;923 return;
873 };924 };
925 self.focus_ring = false;
874 if !self.popups.is_empty() {926 if !self.popups.is_empty() {
875 let under = self.popups.iter().rposition(|popup| {927 let under = self.popups.iter().rposition(|popup| {
876 self.rect(popup.id)928 self.rect(popup.id)
...@@ -945,6 +997,11 @@ impl Ui {...@@ -945,6 +997,11 @@ impl Ui {
945 key: Key::Named(NamedKey::Escape),997 key: Key::Named(NamedKey::Escape),
946 ..998 ..
947 } if !self.popups.is_empty() => self.close_from(self.popups.len() - 1),999 } if !self.popups.is_empty() => self.close_from(self.popups.len() - 1),
1000 Event::Key {
1001 key: Key::Named(key),
1002 ..
1003 } if self.traverse(key) => {}
1004 Event::Access(request) => self.act(request),
948 Event::Key { .. } | Event::Ime(_) => {1005 Event::Key { .. } | Event::Ime(_) => {
949 if let Some(focus) = self.focus {1006 if let Some(focus) = self.focus {
950 self.signals.entry(focus).or_default().events.push(event);1007 self.signals.entry(focus).or_default().events.push(event);
...@@ -1008,15 +1065,32 @@ impl Ui {...@@ -1008,15 +1065,32 @@ impl Ui {
1008 self.popups.iter().position(|popup| popup.id == id)1065 self.popups.iter().position(|popup| popup.id == id)
1009 })1066 })
1010 .max();1067 .max();
1011 self.close_from(within.map_or(0, |within| within + 1));1068 self.push_popup(id, within.map_or(0, |within| within + 1), None);
1069 }
1070
1071 /// Opens popup `id` as the submenu of open popup `from`, beside its row `row`, in place of
1072 /// any other opened from it.
1073 pub fn open_submenu(&mut self, id: Id, from: Id, row: Id) {
1074 let Some(from) = self.popups.iter().position(|popup| popup.id == from) else {
1075 return;
1076 };
1077 if self.popups.get(from + 1).is_none_or(|open| open.id != id) {
1078 self.push_popup(id, from + 1, Some(row));
1079 }
1080 }
1081
1082 /// Closes the popups from `at` on and opens `id` above the rest.
1083 fn push_popup(&mut self, id: Id, at: usize, beside: Option<Id>) {
1084 self.close_from(at);
1012 self.popups.push(Popup {1085 self.popups.push(Popup {
1013 id,1086 id,
1014 focus: self.focus,1087 focus: self.focus,
1015 query: String::new(),1088 query: String::new(),
1016 highlight: None,1089 highlight: None,
1017 picked: None,1090 picked: None,
1018 height: None,
1019 opened: self.now,1091 opened: self.now,
1092 beside,
1093 submenu: None,
1020 });1094 });
1021 self.focus = Some(id);1095 self.focus = Some(id);
1022 self.states.entry(id).or_default().touched = self.frame;1096 self.states.entry(id).or_default().touched = self.frame;
...@@ -1026,9 +1100,13 @@ impl Ui {...@@ -1026,9 +1100,13 @@ impl Ui {
1026 self.popups.iter().any(|popup| popup.id == id)1100 self.popups.iter().any(|popup| popup.id == id)
1027 }1101 }
10281102
1029 /// Closes popup `id` and those opened from it, returning the focus it took.1103 /// Closes popup `id` and those opened from it, returning the focus it took; a submenu
1104 /// closes the menus it opened from with it, as choosing from it ends them.
1030 pub fn close_popup(&mut self, id: Id) {1105 pub fn close_popup(&mut self, id: Id) {
1031 if let Some(index) = self.popups.iter().position(|popup| popup.id == id) {1106 if let Some(mut index) = self.popups.iter().position(|popup| popup.id == id) {
1107 while index > 0 && self.popups[index].beside.is_some() {
1108 index -= 1;
1109 }
1032 self.close_from(index);1110 self.close_from(index);
1033 }1111 }
1034 }1112 }
...@@ -1071,7 +1149,7 @@ impl Ui {...@@ -1071,7 +1149,7 @@ impl Ui {
1071 }1149 }
10721150
1073 /// The box being built, which new boxes become children of.1151 /// The box being built, which new boxes become children of.
1074 pub(crate) fn current(&self) -> Id {1152 pub fn current(&self) -> Id {
1075 self.nodes[*self.stack.last().unwrap()].id1153 self.nodes[*self.stack.last().unwrap()].id
1076 }1154 }
10771155
...@@ -1147,6 +1225,12 @@ impl Ui {...@@ -1147,6 +1225,12 @@ impl Ui {
1147 *value1225 *value
1148 }1226 }
11491227
1228 /// Makes `family` the interface's font, where fontique doesn't know the system's, as
1229 /// with Mac OS X 10.6's Lucida Grande.
1230 pub fn set_system_font(&mut self, family: &str) {
1231 self.texts.set_system_font(family);
1232 }
1233
1150 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a1234 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a
1151 /// missing family lays out in.1235 /// missing family lays out in.
1152 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {1236 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {
...@@ -1339,11 +1423,17 @@ impl Ui {...@@ -1339,11 +1423,17 @@ impl Ui {
1339 self.paint(index, None, None);1423 self.paint(index, None, None);
1340 }1424 }
1341 self.modal = if self.popups.is_empty() { 0 } else { beneath };1425 self.modal = if self.popups.is_empty() { 0 } else { beneath };
1342 for id in [&mut self.hover, &mut self.active, &mut self.focus] {1426 for id in [
1427 &mut self.hover,
1428 &mut self.active,
1429 &mut self.focus,
1430 &mut self.resume,
1431 ] {
1343 if id.is_some_and(|id| !self.states.contains_key(&id)) {1432 if id.is_some_and(|id| !self.states.contains_key(&id)) {
1344 *id = None;1433 *id = None;
1345 }1434 }
1346 }1435 }
1436 self.stops = self.stops();
1347 }1437 }
13481438
1349 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.1439 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.
...@@ -1380,7 +1470,7 @@ impl Ui {...@@ -1380,7 +1470,7 @@ impl Ui {
1380 if anchor == Anchor::Dialog {1470 if anchor == Anchor::Dialog {
1381 self.display.push(Display::Rect {1471 self.display.push(Display::Rect {
1382 rect: self.nodes[0].rect,1472 rect: self.nodes[0].rect,
1383 fill: [0.0, 0.0, 0.0, self.theme.shadow[3] * 0.5 * open],1473 fill: [0.0, 0.0, 0.0, self.theme.shadow[3] * open],
1384 shade: None,1474 shade: None,
1385 border: None,1475 border: None,
1386 radius: 0.0,1476 radius: 0.0,
...@@ -1614,6 +1704,21 @@ impl Ui {...@@ -1614,6 +1704,21 @@ impl Ui {
1614 if inner_clip != clip {1704 if inner_clip != clip {
1615 self.display.push(Display::Clip(clip));1705 self.display.push(Display::Clip(clip));
1616 }1706 }
1707 let node = &self.nodes[index];
1708 if self.focus_ring
1709 && self.focus == Some(node.id)
1710 && node.access.is_some()
1711 && node.anchor.is_none()
1712 && !node.flags.contains(Flags::CUSTOM)
1713 {
1714 let size = size.map(|side| side + 2.0 * FOCUS_RING);
1715 self.display.push(Display::Path {
1716 data: outline(Shape::Rounded, size, node.radius + FOCUS_RING),
1717 origin: [painted[0] - FOCUS_RING, painted[1] - FOCUS_RING],
1718 style: PathStyle::Stroke(FOCUS_RING),
1719 colors: [self.theme.accent; 2],
1720 });
1721 }
1617 if own.is_some() {1722 if own.is_some() {
1618 self.display.push(Display::Motion(motion));1723 self.display.push(Display::Motion(motion));
1619 }1724 }
...@@ -1812,13 +1917,13 @@ impl Ui {...@@ -1812,13 +1917,13 @@ impl Ui {
1812 pub(crate) fn field(1917 pub(crate) fn field(
1813 &mut self,1918 &mut self,
1814 id: Id,1919 id: Id,
1815 ) -> (&mut Selection, &mut (Selection, SelectionUnit), &mut bool) {1920 ) -> (
1921 &mut Selection,
1922 &mut (Selection, SelectionUnit),
1923 &mut Option<[usize; 2]>,
1924 ) {
1816 let state = self.states.entry(id).or_default();1925 let state = self.states.entry(id).or_default();
1817 (1926 (&mut state.selection, &mut state.press, &mut state.select)
1818 &mut state.selection,
1819 &mut state.press,
1820 &mut state.select_all,
1821 )
1822 }1927 }
18231928
1824 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {1929 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
...@@ -1881,6 +1986,7 @@ impl Built {...@@ -1881,6 +1986,7 @@ impl Built {
1881 marks: Vec::new(),1986 marks: Vec::new(),
1882 fold: spec.fold,1987 fold: spec.fold,
1883 fade: spec.fade,1988 fade: spec.fade,
1989 access: spec.role.map(accesskit::Node::new),
1884 hidden: false,1990 hidden: false,
1885 computed: [0.0; 2],1991 computed: [0.0; 2],
1886 relative: [0.0; 2],1992 relative: [0.0; 2],
crates/ui/src/popup.rs+284-69
...@@ -6,6 +6,7 @@ use crate::{...@@ -6,6 +6,7 @@ use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec,6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec,
7 TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field,7 TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field,
8};8};
9use accesskit::Role;
9use nucleo_matcher::{10use nucleo_matcher::{
10 Config, Matcher, Utf32Str,11 Config, Matcher, Utf32Str,
11 pattern::{AtomKind, CaseMatching, Normalization, Pattern},12 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
...@@ -21,6 +22,8 @@ const ROW: f32 = 26.0;...@@ -21,6 +22,8 @@ const ROW: f32 = 26.0;
21pub(crate) const MENU_ROW: f32 = 22.0;22pub(crate) const MENU_ROW: f32 = 22.0;
22/// Rows a list shows before it scrolls.23/// Rows a list shows before it scrolls.
23const ROWS: f32 = 12.0;24const ROWS: f32 = 12.0;
25/// How long the pointer rests on a row before its submenu opens, as Windows waits by default.
26const SUBMENU_DELAY: Duration = Duration::from_millis(200);
24const NARROWEST: f32 = 140.0;27const NARROWEST: f32 = 140.0;
25const PALETTE: f32 = 560.0;28const PALETTE: f32 = 560.0;
26/// Where a tooltip's description wraps.29/// Where a tooltip's description wraps.
...@@ -29,8 +32,6 @@ const TIP_WIDTH: f32 = 280.0;...@@ -29,8 +32,6 @@ const TIP_WIDTH: f32 = 280.0;
29const CELL: f32 = 22.0;32const CELL: f32 = 22.0;
30/// Inset of an item's text from the highlight it previews.33/// Inset of an item's text from the highlight it previews.
31const SAMPLE_PAD: f32 = 2.0;34const SAMPLE_PAD: f32 = 2.0;
32/// Seconds a popup takes to ease to the height of its results.
33const RESIZE: f32 = 0.15;
34thread_local! {35thread_local! {
35 /// Scratch space for ranking, a few hundred kilobytes, reused across frames.36 /// Scratch space for ranking, a few hundred kilobytes, reused across frames.
36 static MATCHER: RefCell<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT));37 static MATCHER: RefCell<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT));
...@@ -57,13 +58,17 @@ pub struct Item<'a> {...@@ -57,13 +58,17 @@ pub struct Item<'a> {
57 pub ink: Option<[f32; 4]>,58 pub ink: Option<[f32; 4]>,
58 pub highlight: Option<[f32; 4]>,59 pub highlight: Option<[f32; 4]>,
59 pub icon: Option<&'static [&'static str]>,60 pub icon: Option<&'static [&'static str]>,
60 /// The icon has colours of its own, so the text's colour does not tint it.61 /// What the icon's `currentColor` paints in place of the text's colour, as a section's
61 pub colored: bool,62 /// icon takes its colour; white leaves an icon with colours of its own as drawn.
63 pub tint: Option<[f32; 4]>,
62 /// Keys that run the item, shown dim at the trailing edge.64 /// Keys that run the item, shown dim at the trailing edge.
63 pub shortcut: &'a str,65 pub shortcut: &'a str,
64 /// Marked with a check in place of its icon.66 /// Whether a check marks it in place of its icon; `None` where it is not a toggle.
65 pub checked: bool,67 pub checked: Option<bool>,
66 /// Starts highlighted when the menu opens, as a checked item does.68 /// Opens a further menu, as a trailing arrow shows.
69 pub submenu: bool,
70 /// The value a picker opens on, highlighted and scrolled into view; a command menu
71 /// has none and opens at its top, however its items are checked.
67 pub current: bool,72 pub current: bool,
68 pub disabled: bool,73 pub disabled: bool,
69 /// Starts a group, ruled off from the one above while unfiltered.74 /// Starts a group, ruled off from the one above while unfiltered.
...@@ -72,6 +77,13 @@ pub struct Item<'a> {...@@ -72,6 +77,13 @@ pub struct Item<'a> {
72 pub heading: bool,77 pub heading: bool,
73}78}
7479
80impl Item<'_> {
81 /// What the row shows at its trailing edge: an arrow where it opens a menu, else its keys.
82 fn trailing(&self) -> &str {
83 if self.submenu { "›" } else { self.shortcut }
84 }
85}
86
75/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a87/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
76/// filter field showing `filter` while empty when given. Returns the index of the item88/// filter field showing `filter` while empty when given. Returns the index of the item
77/// chosen.89/// chosen.
...@@ -101,10 +113,12 @@ pub fn menu(...@@ -101,10 +113,12 @@ pub fn menu(
101 0.0113 0.0
102 },114 },
103 ),115 ),
104 shortcut.max(measure(item.shortcut)),116 shortcut.max(measure(item.trailing())),
105 ]117 ]
106 });118 });
107 let icons = items.iter().any(|item| item.icon.is_some() || item.checked);119 let icons = items
120 .iter()
121 .any(|item| item.icon.is_some() || item.checked == Some(true));
108 let width =122 let width =
109 text + if shortcut > 0.0 {123 text + if shortcut > 0.0 {
110 3.0 * ICON_GAP + shortcut124 3.0 * ICON_GAP + shortcut
...@@ -125,18 +139,73 @@ pub fn menu(...@@ -125,18 +139,73 @@ pub fn menu(
125 choose(139 choose(
126 ui,140 ui,
127 id,141 id,
142 Role::Menu,
128 anchor,143 anchor,
129 items,144 &[("", items)],
130 filter,145 filter,
131 width.max(least).max(NARROWEST),146 width.max(least).max(NARROWEST),
132 style.row,147 style.row,
133 )148 )
149 .map(|(_, index)| index)
150}
151
152/// Opens the submenus of open menu `id` of `items` beside their rows, as `submenu` names
153/// them by item: a row's once the pointer rests on it for `SUBMENU_DELAY`, or at once on
154/// Right or a click. Resting on another row closes it.
155pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> Option<Id>) {
156 let Some(highlight) = ui
157 .popups
158 .iter()
159 .find(|popup| popup.id == id)
160 .map(|popup| popup.highlight)
161 else {
162 return;
163 };
164 let rows = id.child("rows");
165 let opens = |key: u64| {
166 items
167 .get(key as usize)
168 .is_some_and(|item| item.submenu && !item.disabled)
169 };
170 let right =
171 highlight.is_some_and(opens) && !navigation(ui, &[id], &[NamedKey::ArrowRight]).is_empty();
172 let under = highlight.filter(|key| {
173 ui.pointer
174 .zip(ui.rect(rows.child(*key)))
175 .is_some_and(|(point, rect)| crate::contains(rect, point))
176 });
177 let now = ui.now;
178 let popup = state(ui, id);
179 if right {
180 popup.submenu = highlight.map(|key| (key, Some(now)));
181 } else if let Some(key) = under
182 && popup.submenu.is_none_or(|(rested, _)| rested != key)
183 {
184 popup.submenu = Some((key, Some(now + SUBMENU_DELAY)));
185 }
186 let Some((key, Some(due))) = popup.submenu else {
187 return;
188 };
189 if now < due {
190 ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due)));
191 return;
192 }
193 popup.submenu = Some((key, None));
194 match submenu(key as usize).filter(|_| opens(key)) {
195 Some(child) => ui.open_submenu(child, id, rows.child(key)),
196 None => {
197 if let Some(at) = ui.popups.iter().position(|popup| popup.id == id) {
198 ui.close_from(at + 1);
199 }
200 }
201 }
134}202}
135203
136/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip204/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip
137/// below the box built last while the pointer rests on it or on a box inside it: after a205/// below the box built last while the pointer rests on it or on a box inside it: after a
138/// delay, or at once while another has just shown. A press or the wheel hides it until the206/// delay, or at once while another has just shown. A press or the wheel hides it until the
139/// pointer leaves.207/// pointer leaves. It names the box to assistive technology, or where the box has no role,
208/// the unnamed controls inside it.
140pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {209pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {
141 tooltip_below(ui, None, title, keys, description);210 tooltip_below(ui, None, title, keys, description);
142}211}
...@@ -157,6 +226,27 @@ fn tooltip_below(...@@ -157,6 +226,27 @@ fn tooltip_below(
157 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {226 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {
158 return;227 return;
159 };228 };
229 if part.is_none() {
230 let own = ui.nodes[index].access.is_some();
231 let controls = ui.nodes[index..].iter_mut().filter_map(|node| {
232 node.access
233 .as_mut()
234 .filter(|access| own || !crate::access::holds(access.role()))
235 });
236 for (count, node) in controls.take(if own { 1 } else { usize::MAX }).enumerate() {
237 if node.label().is_none() {
238 node.set_label(title);
239 }
240 if count == 0 {
241 if !keys.is_empty() {
242 node.set_keyboard_shortcut(keys);
243 }
244 if let Some(description) = description {
245 node.set_description(description);
246 }
247 }
248 }
249 }
160 // The boxes built since are the box's own.250 // The boxes built since are the box's own.
161 let hovered = ui251 let hovered = ui
162 .hover252 .hover
...@@ -238,31 +328,62 @@ fn tooltip_below(...@@ -238,31 +328,62 @@ fn tooltip_below(
238}328}
239329
240/// Builds popup `id` as a command palette across the top of the window while it is open,330/// Builds popup `id` as a command palette across the top of the window while it is open,
241/// listing `items` that match what is typed, most relevant first, under a filter field331/// under a filter field showing `placeholder` while empty. What is typed picks the first of
242/// showing `placeholder` while empty. Returns the index of the item chosen.332/// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most
243pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option<usize> {333/// relevant first. Returns the mode and the index of the item chosen.
334pub fn palette(
335 ui: &mut Ui,
336 id: Id,
337 modes: &[(&str, &[Item])],
338 placeholder: &str,
339) -> Option<(usize, usize)> {
244 if !ui.popup_open(id) {340 if !ui.popup_open(id) {
245 return None;341 return None;
246 }342 }
247 let window = ui.rect(Id::ROOT).unwrap_or_default();343 let window = ui.rect(Id::ROOT).unwrap_or_default();
248 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);344 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
249 choose(ui, id, Anchor::Top, items, Some(placeholder), width, ROW)345 choose(
346 ui,
347 id,
348 Role::Dialog,
349 Anchor::Top,
350 modes,
351 Some(placeholder),
352 width,
353 ROW,
354 )
250}355}
251356
357/// Opens popup `id` with `query` typed in its filter field and the caret after it, or
358/// retypes it where the popup is open.
359pub fn open_with(ui: &mut Ui, id: Id, query: &str) {
360 ui.open_popup(id);
361 let popup = state(ui, id);
362 popup.query = query.to_owned();
363 popup.highlight = None;
364 ui.states.entry(id.child("filter")).or_default().select = Some([usize::MAX; 2]);
365}
366
367/// Builds popup `id` as a `role` under a filter field showing `filter` while empty, listing
368/// the items of the first of `modes` whose prefix the query starts with: a menu of items, or
369/// a dialog of a list box of options.
370#[allow(clippy::too_many_arguments)]
252fn choose(371fn choose(
253 ui: &mut Ui,372 ui: &mut Ui,
254 id: Id,373 id: Id,
374 role: Role,
255 anchor: Anchor,375 anchor: Anchor,
256 items: &[Item],376 modes: &[(&str, &[Item])],
257 filter: Option<&str>,377 filter: Option<&str>,
258 width: f32,378 width: f32,
259 row: f32,379 row: f32,
260) -> Option<usize> {380) -> Option<(usize, usize)> {
261 let field = id.child("filter");381 let field = id.child("filter");
262 if filter.is_some() && ui.focus == Some(id) {382 if filter.is_some() && ui.focus == Some(id) {
263 ui.focus = Some(field);383 ui.focus = Some(field);
264 }384 }
265 let keys = navigation(ui, &[id, field], &KEYS);385 let keys = navigation(ui, &[id, field], &KEYS);
386 let rows = id.child("rows");
266 let popup = state(ui, id);387 let popup = state(ui, id);
267 let mut query = std::mem::take(&mut popup.query);388 let mut query = std::mem::take(&mut popup.query);
268 let mut highlight = popup.highlight;389 let mut highlight = popup.highlight;
...@@ -273,7 +394,7 @@ fn choose(...@@ -273,7 +394,7 @@ fn choose(
273 Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]),394 Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]),
274 _ => (Flags::default(), ROW),395 _ => (Flags::default(), ROW),
275 };396 };
276 surface(ui, id, anchor, width);397 surface(ui, id, role, anchor, width);
277 if let Some(placeholder) = filter {398 if let Some(placeholder) = filter {
278 let before = query.clone();399 let before = query.clone();
279 text_field(400 text_field(
...@@ -288,21 +409,32 @@ fn choose(...@@ -288,21 +409,32 @@ fn choose(
288 border: Some(theme.accent),409 border: Some(theme.accent),
289 radius: 4.0,410 radius: 4.0,
290 pad: [8.0, 0.0],411 pad: [8.0, 0.0],
412 role: Some(Role::SearchInput),
291 ..Spec::default()413 ..Spec::default()
292 },414 },
293 );415 );
416 // New results show from their top at once.
294 if query != before {417 if query != before {
295 highlight = None;418 highlight = None;
419 ui.lists.remove(&rows);
296 }420 }
297 }421 }
298 let matches = Matches::new(items, &query, style.rule_band);422 let (mode, (prefix, items)) = modes
299 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.423 .iter()
424 .enumerate()
425 .find(|(_, (prefix, _))| query.starts_with(prefix))
426 .expect("a mode takes any query");
427 if role == Role::Dialog
428 && let Some(node) = ui.access(id)
429 {
430 node.set_label(filter.unwrap_or_default());
431 }
432 let typed = &query[prefix.len()..];
433 let matches = Matches::new(items, typed, style.rule_band);
434 // Unfiltered, the current item starts highlighted; filtered, the best match.
300 highlight = highlight.or_else(|| {435 highlight = highlight.or_else(|| {
301 let first = if query.is_empty() {436 let first = if typed.is_empty() {
302 matches437 matches.order.iter().position(|index| items[*index].current)
303 .order
304 .iter()
305 .position(|index| items[*index].current || items[*index].checked)
306 } else {438 } else {
307 (0..matches.count()).find(|row| matches.selectable(*row))439 (0..matches.count()).find(|row| matches.selectable(*row))
308 };440 };
...@@ -318,24 +450,6 @@ fn choose(...@@ -318,24 +450,6 @@ fn choose(
318 let view = content450 let view = content
319 .max(row)451 .max(row)
320 .min((ROWS * row).min(window - 4.0 * PAD - field).max(row));452 .min((ROWS * row).min(window - 4.0 * PAD - field).max(row));
321 // The popup eases to its new height as the results change; the rows do not move.
322 let dt = ui.dt;
323 let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]);
324 *elapsed = (*elapsed + dt).min(RESIZE);
325 let height = *to - (*to - *from) * (1.0 - *elapsed / RESIZE).powi(3);
326 if *to != view {
327 [*from, *to, *elapsed] = [height, view, 0.0];
328 }
329 ui.animating |= height != view;
330 ui.open(
331 "results",
332 Spec {
333 flags: Flags::CLIP,
334 axis: Axis::Y,
335 size: [fill(), px(height)],
336 ..Spec::default()
337 },
338 );
339 let chosen = if matches.count() == 0 {453 let chosen = if matches.count() == 0 {
340 ui.leaf(454 ui.leaf(
341 "empty",455 "empty",
...@@ -358,16 +472,22 @@ fn choose(...@@ -358,16 +472,22 @@ fn choose(
358 };472 };
359 let clicked = crate::list(473 let clicked = crate::list(
360 ui,474 ui,
361 id.child("rows"),475 rows,
362 Spec {476 Spec {
363 size: [fill(), px(view)],477 size: [fill(), px(view)],
364 fill: Some(style.fill),478 fill: Some(style.fill),
479 role: (role == Role::Dialog).then_some(Role::ListBox),
365 ..Spec::default()480 ..Spec::default()
366 },481 },
367 list,482 list,
368 &mut highlight,483 &mut highlight,
369 |ui, row| menu_row(ui, &style, &matches, row),484 |ui, row| menu_row(ui, &style, &matches, role, row),
370 );485 );
486 // The keys choose from the list while the focus stays where they are typed.
487 let active = highlight.filter(|key| matches.find(*key).is_some());
488 for owner in [id, id.child("filter")] {
489 highlighted(ui, owner, active.map(|key| rows.child(key)));
490 }
371 let entered = keys.contains(&NamedKey::Enter);491 let entered = keys.contains(&NamedKey::Enter);
372 clicked.or_else(|| {492 clicked.or_else(|| {
373 highlight493 highlight
...@@ -376,10 +496,14 @@ fn choose(...@@ -376,10 +496,14 @@ fn choose(
376 })496 })
377 };497 };
378 ui.close();498 ui.close();
379 ui.close();
380 if let Some(row) = chosen {499 if let Some(row) = chosen {
381 ui.close_popup(id);500 let index = matches.order[row];
382 return Some(matches.order[row]);501 // A row opening a submenu opens it beside itself, keeping the menu, as `submenus` builds.
502 if !items[index].submenu {
503 ui.close_popup(id);
504 return Some((mode, index));
505 }
506 state(ui, id).submenu = Some((matches.key(row), Some(ui.now)));
383 }507 }
384 let popup = state(ui, id);508 let popup = state(ui, id);
385 popup.query = query;509 popup.query = query;
...@@ -388,9 +512,39 @@ fn choose(...@@ -388,9 +512,39 @@ fn choose(
388}512}
389513
390/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the514/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
391/// highlight when selected, under a rule when it starts a group.515/// highlight when selected, under a rule when it starts a group. In a dialog's list it is
392fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {516/// an option.
517fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row) {
393 let item = &matches.items[row.key as usize];518 let item = &matches.items[row.key as usize];
519 let current = ui.current();
520 if let Some(node) = ui.access(current) {
521 node.set_role(match owner {
522 _ if item.heading => Role::Heading,
523 Role::Dialog => Role::ListBoxOption,
524 _ if item.checked.is_some() => Role::MenuItemCheckBox,
525 _ => Role::MenuItem,
526 });
527 node.set_label(item.text);
528 if let Some(checked) = item.checked {
529 node.set_toggled(checked.into());
530 }
531 if item.submenu {
532 node.set_has_popup(accesskit::HasPopup::Menu);
533 node.set_expanded(false);
534 }
535 if row.selected {
536 node.set_selected(true);
537 }
538 if item.disabled {
539 node.set_disabled();
540 }
541 match item.shortcut {
542 "" => {}
543 // A palette's trailing text may say where an option lies rather than its keys.
544 trailing if owner == Role::Dialog => node.set_description(trailing),
545 keys => node.set_keyboard_shortcut(keys),
546 }
547 }
394 if row.index > 0 && matches.ruled && item.separated {548 if row.index > 0 && matches.ruled && item.separated {
395 ui.leaf(549 ui.leaf(
396 "rule",550 "rule",
...@@ -424,16 +578,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {...@@ -424,16 +578,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {
424 },578 },
425 );579 );
426 if matches.icons {580 if matches.icons {
427 let tint = if item.colored && !item.checked {581 let checked = item.checked == Some(true);
428 [1.0, 1.0, 1.0, color[3]]582 let tint = match item.tint {
429 } else {583 Some([red, green, blue, _]) if !checked => [red, green, blue, color[3]],
430 color584 _ => color,
431 };585 };
432 ui.leaf(586 ui.leaf(
433 "icon",587 "icon",
434 Spec {588 Spec {
435 size: [px(ICON), fill()],589 size: [px(ICON), fill()],
436 icon: if item.checked { Some(CHECK) } else { item.icon },590 icon: if checked { Some(CHECK) } else { item.icon },
437 color: Some(if item.disabled {591 color: Some(if item.disabled {
438 mix(tint, style.fill, 0.5)592 mix(tint, style.fill, 0.5)
439 } else {593 } else {
...@@ -486,12 +640,12 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {...@@ -486,12 +640,12 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {
486 ui.leaf("text", text);640 ui.leaf("text", text);
487 }641 }
488 }642 }
489 if !item.shortcut.is_empty() {643 if !item.trailing().is_empty() {
490 ui.leaf(644 ui.leaf(
491 "shortcut",645 "shortcut",
492 Spec {646 Spec {
493 size: [fit(), fill()],647 size: [fit(), fill()],
494 text: Some(item.shortcut),648 text: Some(item.trailing()),
495 font_size: Some(style.font_size),649 font_size: Some(style.font_size),
496 color: Some(style.dim),650 color: Some(style.dim),
497 ..Spec::default()651 ..Spec::default()
...@@ -501,15 +655,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {...@@ -501,15 +655,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {
501 ui.close();655 ui.close();
502}656}
503657
504/// Builds popup `id` as a grid of `swatches` in rows of `columns` beside `anchor` while it658/// Builds popup `id` as a grid of `swatches`, each a colour and its name or `""` to be
505/// is open, under a button labelled `none` for no colour of its own. Returns the swatch659/// named by its hex, in rows of `columns` beside `anchor` while it is open, under a button
506/// chosen, or None for the button.660/// labelled `none` for no colour of its own. Returns the swatch chosen, or None for the
661/// button.
507pub fn colors(662pub fn colors(
508 ui: &mut Ui,663 ui: &mut Ui,
509 id: Id,664 id: Id,
510 anchor: Anchor,665 anchor: Anchor,
511 none: &str,666 none: &str,
512 swatches: &[[f32; 4]],667 swatches: &[([f32; 4], &str)],
513 columns: usize,668 columns: usize,
514) -> Option<Option<[f32; 4]>> {669) -> Option<Option<[f32; 4]>> {
515 if !ui.popup_open(id) {670 if !ui.popup_open(id) {
...@@ -557,7 +712,7 @@ pub fn colors(...@@ -557,7 +712,7 @@ pub fn colors(
557 }712 }
558 if let Some(index) = chosen {713 if let Some(index) = chosen {
559 ui.close_popup(id);714 ui.close_popup(id);
560 return Some((index > 0).then(|| swatches[index - 1]));715 return Some((index > 0).then(|| swatches[index - 1].0));
561 }716 }
562717
563 let theme = ui.theme.clone();718 let theme = ui.theme.clone();
...@@ -565,6 +720,7 @@ pub fn colors(...@@ -565,6 +720,7 @@ pub fn colors(
565 surface(720 surface(
566 ui,721 ui,
567 id,722 id,
723 Role::Menu,
568 anchor,724 anchor,
569 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,725 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
570 );726 );
...@@ -577,6 +733,7 @@ pub fn colors(...@@ -577,6 +733,7 @@ pub fn colors(
577 fill: lit(0),733 fill: lit(0),
578 radius: 4.0,734 radius: 4.0,
579 pad: [8.0, 0.0],735 pad: [8.0, 0.0],
736 role: Some(Role::MenuItem),
580 ..Spec::default()737 ..Spec::default()
581 },738 },
582 );739 );
...@@ -589,7 +746,7 @@ pub fn colors(...@@ -589,7 +746,7 @@ pub fn colors(
589 ..Spec::default()746 ..Spec::default()
590 },747 },
591 );748 );
592 for (column, color) in colors.iter().enumerate() {749 for (column, (color, name)) in colors.iter().enumerate() {
593 let index = 1 + row * columns + column;750 let index = 1 + row * columns + column;
594 ui.open_as(751 ui.open_as(
595 cell(index),752 cell(index),
...@@ -599,9 +756,18 @@ pub fn colors(...@@ -599,9 +756,18 @@ pub fn colors(
599 fill: lit(index),756 fill: lit(index),
600 radius: 4.0,757 radius: 4.0,
601 pad: [3.0, 3.0],758 pad: [3.0, 3.0],
759 role: Some(Role::MenuItem),
602 ..Spec::default()760 ..Spec::default()
603 },761 },
604 );762 );
763 if let Some(node) = ui.access(cell(index)) {
764 if name.is_empty() {
765 let [red, green, blue] = draw::srgb_bytes(*color);
766 node.set_label(format!("#{red:02X}{green:02X}{blue:02X}"));
767 } else {
768 node.set_label(*name);
769 }
770 }
605 ui.leaf(771 ui.leaf(
606 "swatch",772 "swatch",
607 Spec {773 Spec {
...@@ -616,11 +782,19 @@ pub fn colors(...@@ -616,11 +782,19 @@ pub fn colors(
616 }782 }
617 ui.close();783 ui.close();
618 }784 }
785 highlighted(ui, id, highlight.map(cell));
619 ui.close();786 ui.close();
620 state(ui, id).highlight = highlight.map(|cell| cell as u64);787 state(ui, id).highlight = highlight.map(|cell| cell as u64);
621 None788 None
622}789}
623790
791/// Tells assistive technology the keys of `id` highlight `cell`, as a focus within it.
792fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {
793 if let (Some(cell), Some(node)) = (cell, ui.access(id)) {
794 node.set_active_descendant(cell.node());
795 }
796}
797
624/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,798/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
625/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the799/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
626/// columns and rows chosen.800/// columns and rows chosen.
...@@ -677,6 +851,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op...@@ -677,6 +851,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
677 surface(851 surface(
678 ui,852 ui,
679 id,853 id,
854 Role::Menu,
680 anchor,855 anchor,
681 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,856 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
682 );857 );
...@@ -718,9 +893,13 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op...@@ -718,9 +893,13 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
718 flags: Flags::CLICKABLE,893 flags: Flags::CLICKABLE,
719 size: [px(CELL), px(CELL)],894 size: [px(CELL), px(CELL)],
720 pad: [3.0, 3.0],895 pad: [3.0, 3.0],
896 role: Some(Role::MenuItem),
721 ..Spec::default()897 ..Spec::default()
722 },898 },
723 );899 );
900 if let Some(node) = ui.access(cell(row * columns + column)) {
901 node.set_label(format!("{}x{} Table", column + 1, row + 1));
902 }
724 ui.leaf(903 ui.leaf(
725 "square",904 "square",
726 Spec {905 Spec {
...@@ -740,6 +919,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op...@@ -740,6 +919,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
740 ui.close();919 ui.close();
741 }920 }
742 ui.close();921 ui.close();
922 highlighted(ui, id, highlight.map(cell));
743 ui.close();923 ui.close();
744 state(ui, id).highlight = highlight.map(|cell| cell as u64);924 state(ui, id).highlight = highlight.map(|cell| cell as u64);
745 None925 None
...@@ -825,7 +1005,13 @@ pub fn gallery(...@@ -825,7 +1005,13 @@ pub fn gallery(
825 .iter()1005 .iter()
826 .map(|group| group.columns as f32 * group.size[0])1006 .map(|group| group.columns as f32 * group.size[0])
827 .fold(0.0, f32::max);1007 .fold(0.0, f32::max);
828 surface(ui, id, anchor, width + 2.0 * ui.theme.menu().pad);1008 surface(
1009 ui,
1010 id,
1011 Role::Menu,
1012 anchor,
1013 width + 2.0 * ui.theme.menu().pad,
1014 );
829 let mut index = 0;1015 let mut index = 0;
830 for (number, group) in groups.iter().enumerate() {1016 for (number, group) in groups.iter().enumerate() {
831 ui.leaf(1017 ui.leaf(
...@@ -859,9 +1045,15 @@ pub fn gallery(...@@ -859,9 +1045,15 @@ pub fn gallery(
859 border: current.contains(&index).then_some(theme.accent),1045 border: current.contains(&index).then_some(theme.accent),
860 radius: 4.0,1046 radius: 4.0,
861 pad: [PAD, PAD],1047 pad: [PAD, PAD],
1048 role: Some(Role::MenuItem),
862 ..Spec::default()1049 ..Spec::default()
863 },1050 },
864 );1051 );
1052 if current.contains(&index)
1053 && let Some(node) = ui.access(cell_id(index))
1054 {
1055 node.set_selected(true);
1056 }
865 cell(ui, index);1057 cell(ui, index);
866 ui.close();1058 ui.close();
867 index += 1;1059 index += 1;
...@@ -869,6 +1061,7 @@ pub fn gallery(...@@ -869,6 +1061,7 @@ pub fn gallery(
869 ui.close();1061 ui.close();
870 }1062 }
871 }1063 }
1064 highlighted(ui, id, highlight.map(cell_id));
872 ui.close();1065 ui.close();
873 state(ui, id).highlight = highlight.map(|cell| cell as u64);1066 state(ui, id).highlight = highlight.map(|cell| cell as u64);
874 None1067 None
...@@ -931,7 +1124,16 @@ pub fn color_picker(...@@ -931,7 +1124,16 @@ pub fn color_picker(
931 }1124 }
9321125
933 let theme = ui.theme.clone();1126 let theme = ui.theme.clone();
934 surface(ui, id, anchor, FIELD[0] + 2.0 * ui.theme.menu().pad);1127 surface(
1128 ui,
1129 id,
1130 Role::Dialog,
1131 anchor,
1132 FIELD[0] + 2.0 * ui.theme.menu().pad,
1133 );
1134 if let Some(node) = ui.access(id) {
1135 node.set_label(title);
1136 }
935 let color = |hsl| {1137 let color = |hsl| {
936 let [red, green, blue] = from_hsl(hsl);1138 let [red, green, blue] = from_hsl(hsl);
937 draw::srgb(red, green, blue)1139 draw::srgb(red, green, blue)
...@@ -1095,11 +1297,21 @@ fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] {...@@ -1095,11 +1297,21 @@ fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] {
1095 [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8)1297 [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8)
1096}1298}
10971299
1098/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.1300/// Opens popup `id`'s panel, a `role` `width` wide beside `anchor`; the caller closes it.
1099fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {1301fn surface(ui: &mut Ui, id: Id, role: Role, anchor: Anchor, width: f32) {
1100 let style = ui.theme.menu();1302 let style = ui.theme.menu();
1101 let pad = style.pad;1303 let pad = style.pad;
1102 let anchor = match anchor {1304 // Level with its row, past the menu's edges.
1305 let beside = ui
1306 .popups
1307 .iter()
1308 .position(|popup| popup.id == id)
1309 .and_then(|at| {
1310 let [_, top, _, bottom] = ui.rect(ui.popups[at].beside?)?;
1311 let [left, _, right, _] = ui.rect(ui.popups[at.checked_sub(1)?].id)?;
1312 Some([left, top, right, bottom])
1313 });
1314 let anchor = match beside.map_or(anchor, Anchor::Right) {
1103 Anchor::Below([left, top, right, bottom]) => {1315 Anchor::Below([left, top, right, bottom]) => {
1104 Anchor::Below([left, top - PAD, right, bottom + PAD])1316 Anchor::Below([left, top - PAD, right, bottom + PAD])
1105 }1317 }
...@@ -1122,6 +1334,7 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {...@@ -1122,6 +1334,7 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
1122 pad: [pad; 2],1334 pad: [pad; 2],
1123 gap: pad,1335 gap: pad,
1124 anchor: Some(anchor),1336 anchor: Some(anchor),
1337 role: Some(role),
1125 ..Spec::default()1338 ..Spec::default()
1126 };1339 };
1127 ui.open_as(id, spec);1340 ui.open_as(id, spec);
...@@ -1225,7 +1438,9 @@ impl<'a> Matches<'a> {...@@ -1225,7 +1438,9 @@ impl<'a> Matches<'a> {
1225 rules,1438 rules,
1226 ruled,1439 ruled,
1227 rule,1440 rule,
1228 icons: items.iter().any(|item| item.icon.is_some() || item.checked),1441 icons: items
1442 .iter()
1443 .any(|item| item.icon.is_some() || item.checked == Some(true)),
1229 }1444 }
1230 }1445 }
1231}1446}
crates/ui/src/shell.rs+126-42
...@@ -7,6 +7,7 @@ use crate::{...@@ -7,6 +7,7 @@ use crate::{
7 Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill,7 Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill,
8 mix, px,8 mix, px,
9};9};
10use accesskit::{HasPopup, Role};
10use std::hash::Hash;11use std::hash::Hash;
1112
12/// A menu's downward arrow.13/// A menu's downward arrow.
...@@ -102,6 +103,7 @@ pub fn section_tabs(...@@ -102,6 +103,7 @@ pub fn section_tabs(
102 ],103 ],
103 fill: Some(fill),104 fill: Some(fill),
104 fade: [fade(offset > 0.5), fade(offset < most - 0.5)],105 fade: [fade(offset > 0.5), fade(offset < most - 0.5)],
106 role: Some(Role::TabList),
105 ..Spec::default()107 ..Spec::default()
106 },108 },
107 );109 );
...@@ -144,13 +146,16 @@ pub fn section_tabs(...@@ -144,13 +146,16 @@ pub fn section_tabs(
144 }146 }
145 };147 };
146 let lift = f32::from(u8::from(lifted == Some(index)));148 let lift = f32::from(u8::from(lifted == Some(index)));
147 let open = ui149 let open = ui.animate(
148 .animate(ui.id(("open", name)), f32::from(u8::from(index == active)))150 ui.id(("open", name)),
149 .max(lift);151 f32::from(u8::from(index == active || lifted == Some(index))),
152 );
150 // Whole device pixels, so a rising tab reuses its rasterized outlines.153 // Whole device pixels, so a rising tab reuses its rasterized outlines.
151 let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale();154 let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale();
152 let colors = theme.section(color);155 let colors = theme.section(color);
153 let fill = mix(colors.tab, section.frame[0], open);156 // A lifted tab rises in its own colour; only the open one takes the frame's.
157 let top = if index == active { section } else { &colors };
158 let fill = mix(colors.tab, top.frame[0], open);
154 let fade = |color: [f32; 4], alpha: f32| [color[0], color[1], color[2], color[3] * alpha];159 let fade = |color: [f32; 4], alpha: f32| [color[0], color[1], color[2], color[3] * alpha];
155 let signal = ui.leaf(160 let signal = ui.leaf(
156 ("tab", index),161 ("tab", index),
...@@ -168,14 +173,18 @@ pub fn section_tabs(...@@ -168,14 +173,18 @@ pub fn section_tabs(
168 gradient: Some(fill),173 gradient: Some(fill),
169 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),174 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),
170 // A tab not open is outlined in its own hue, fainter than the open one's.175 // A tab not open is outlined in its own hue, fainter than the open one's.
171 border: Some(mix(mix(colors.tab, colors.edge, 0.55), section.edge, open)),176 border: Some(mix(mix(colors.tab, colors.edge, 0.55), top.edge, open)),
172 shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35 + 0.15 * lift], open)),177 shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35 + 0.15 * lift], open)),
173 radius: TAB_ROUNDING,178 radius: TAB_ROUNDING,
174 shape: Shape::Tab { lean: lean(height) },179 shape: Shape::Tab { lean: lean(height) },
175 pad: [TAB_PAD, 0.0],180 pad: [TAB_PAD, 0.0],
181 role: Some(Role::Tab),
176 ..Spec::default()182 ..Spec::default()
177 },183 },
178 );184 );
185 if let Some(node) = ui.access(tab_id(row, index)) {
186 node.set_selected(index == active);
187 }
179 if signal.clicked && index != active {188 if signal.clicked && index != active {
180 clicked = Some(index);189 clicked = Some(index);
181 }190 }
...@@ -243,30 +252,53 @@ fn lean(height: f32) -> f32 {...@@ -243,30 +252,53 @@ fn lean(height: f32) -> f32 {
243 (height - 2.0) / 2.0252 (height - 2.0) / 2.0
244}253}
245254
246/// A square button showing `icon` tinted by `tint`, lit while `on`; artwork in its own255/// A square button showing `icon` tinted by `tint`, a toggle lit while `on` is `Some(true)`
247/// colours takes white.256/// or a plain button where it is `None`; artwork in its own colours takes white. A tooltip
257/// names it.
248pub fn tool_button(258pub fn tool_button(
249 ui: &mut Ui,259 ui: &mut Ui,
250 part: impl Hash,260 part: impl Hash,
251 icon: &'static [&'static str],261 icon: &'static [&'static str],
252 tint: [f32; 4],262 tint: [f32; 4],
253 on: bool,263 on: Option<bool>,
254) -> Signal {264) -> Signal {
255 let hover = ui.theme.hover();265 let hover = ui.theme.hover();
256 ui.leaf(266 let id = ui.open(
257 part,267 part,
258 Spec {268 Spec {
259 flags: Flags::CLICKABLE,269 flags: Flags::CLICKABLE,
260 size: [px(TOOL), px(TOOL)],270 size: [px(TOOL), px(TOOL)],
261 icon: Some(icon),271 icon: Some(icon),
262 color: Some(tint),272 color: Some(tint),
263 fill: on.then_some(hover),273 fill: (on == Some(true)).then_some(hover),
264 hover_fill: Some(hover),274 hover_fill: Some(hover),
265 radius: 4.0,275 radius: 4.0,
266 center: true,276 center: true,
277 role: Some(Role::Button),
267 ..Spec::default()278 ..Spec::default()
268 },279 },
269 )280 );
281 press_state(ui, id, on);
282 ui.close();
283 ui.signal(id)
284}
285
286/// Shows button `id` as a toggle, pressed or not, where `on` is given.
287fn press_state(ui: &mut Ui, id: Id, on: Option<bool>) {
288 if let Some(on) = on
289 && let Some(node) = ui.access(id)
290 {
291 node.set_toggled(on.into());
292 }
293}
294
295/// Marks control `id` as opening popup `menu`, and whether it is open.
296fn opens(ui: &mut Ui, id: Id, menu: Id) {
297 let open = ui.popup_open(menu);
298 if let Some(node) = ui.access(id) {
299 node.set_has_popup(HasPopup::Menu);
300 node.set_expanded(open);
301 }
270}302}
271303
272/// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon`304/// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon`
...@@ -280,13 +312,20 @@ pub fn unavailable(...@@ -280,13 +312,20 @@ pub fn unavailable(
280) {312) {
281 let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35];313 let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35];
282 let arrow = faded(ui.theme.text_dim);314 let arrow = faded(ui.theme.text_dim);
283 ui.open(315 let id = ui.open(
284 part,316 part,
285 Spec {317 Spec {
286 size: [children(), px(TOOL)],318 size: [children(), px(TOOL)],
319 role: Some(Role::Button),
287 ..Spec::default()320 ..Spec::default()
288 },321 },
289 );322 );
323 if let Some(node) = ui.access(id) {
324 node.set_disabled();
325 if menu {
326 node.set_has_popup(HasPopup::Menu);
327 }
328 }
290 ui.leaf(329 ui.leaf(
291 "icon",330 "icon",
292 Spec {331 Spec {
...@@ -312,16 +351,19 @@ pub fn unavailable(...@@ -312,16 +351,19 @@ pub fn unavailable(
312 ui.close();351 ui.close();
313}352}
314353
315/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`,354/// A button `name`d and showing `icon`, a toggle lit while `on` as `tool_button`'s, joined
316/// with the colour it applies as a bar under the icon when given. Hovering the button fills355/// to an arrow that opens popup `menu`, with the colour it applies as a bar under the icon
317/// it alone; hovering the arrow, or its menu being open, outlines both as one control.356/// when given. Hovering the button fills it alone; hovering the arrow, or its menu being
318/// Returns the button's signal.357/// open, outlines both as one control. The arrow is named "`name` Options", as Office names
358/// its split buttons' arrows. Returns the button's signal.
359#[allow(clippy::too_many_arguments)]
319pub fn split_button(360pub fn split_button(
320 ui: &mut Ui,361 ui: &mut Ui,
321 part: impl Hash,362 part: impl Hash,
363 name: &str,
322 icon: &'static [&'static str],364 icon: &'static [&'static str],
323 bar: Option<[f32; 4]>,365 bar: Option<[f32; 4]>,
324 on: bool,366 on: Option<bool>,
325 menu: Id,367 menu: Id,
326) -> Signal {368) -> Signal {
327 const RADIUS: f32 = 4.0;369 const RADIUS: f32 = 4.0;
...@@ -332,7 +374,7 @@ pub fn split_button(...@@ -332,7 +374,7 @@ pub fn split_button(
332 let [button, arrow] = [split.child("button"), split.child("menu")];374 let [button, arrow] = [split.child("button"), split.child("menu")];
333 let lit = ui.animate(375 let lit = ui.animate(
334 split.child("lit"),376 split.child("lit"),
335 f32::from(u8::from(on || ui.hover == Some(button))),377 f32::from(u8::from(on == Some(true) || ui.hover == Some(button))),
336 );378 );
337 let outlined = ui.hover == Some(arrow) || ui.popup_open(menu);379 let outlined = ui.hover == Some(arrow) || ui.popup_open(menu);
338 let ring = ui.animate(split.child("ring"), f32::from(u8::from(outlined)));380 let ring = ui.animate(split.child("ring"), f32::from(u8::from(outlined)));
...@@ -348,9 +390,14 @@ pub fn split_button(...@@ -348,9 +390,14 @@ pub fn split_button(
348 Spec {390 Spec {
349 flags: Flags::CLICKABLE | Flags::CLIP,391 flags: Flags::CLICKABLE | Flags::CLIP,
350 size: [px(TOOL), px(TOOL)],392 size: [px(TOOL), px(TOOL)],
393 role: Some(Role::Button),
351 ..Spec::default()394 ..Spec::default()
352 },395 },
353 );396 );
397 press_state(ui, button, on);
398 if let Some(node) = ui.access(button) {
399 node.set_label(name);
400 }
354 // Rounded past the clip, so the fill meets the arrow square.401 // Rounded past the clip, so the fill meets the arrow square.
355 ui.leaf(402 ui.leaf(
356 "face",403 "face",
...@@ -379,20 +426,24 @@ pub fn split_button(...@@ -379,20 +426,24 @@ pub fn split_button(
379 }426 }
380 ui.close();427 ui.close();
381 ui.close();428 ui.close();
382 if ui429 ui.open_as(
383 .leaf(430 arrow,
384 "menu",431 Spec {
385 Spec {432 flags: Flags::CLICKABLE,
386 flags: Flags::CLICKABLE,433 size: [px(ARROW), px(TOOL)],
387 size: [px(ARROW), px(TOOL)],434 icon: Some(CHEVRON),
388 icon: Some(CHEVRON),435 color: Some(ui.theme.text_dim),
389 color: Some(ui.theme.text_dim),436 center: true,
390 center: true,437 role: Some(Role::Button),
391 ..Spec::default()438 ..Spec::default()
392 },439 },
393 )440 );
394 .pressed441 opens(ui, arrow, menu);
395 {442 if let Some(node) = ui.access(arrow) {
443 node.set_label(format!("{name} Options"));
444 }
445 ui.close();
446 if ui.signal(arrow).pressed {
396 ui.open_popup(menu);447 ui.open_popup(menu);
397 }448 }
398 ui.leaf(449 ui.leaf(
...@@ -409,12 +460,14 @@ pub fn split_button(...@@ -409,12 +460,14 @@ pub fn split_button(
409 ui.signal(button)460 ui.signal(button)
410}461}
411462
412/// A button showing `icon` and a menu arrow, one control that opens popup `menu`. Returns463/// A button showing `icon` and a menu arrow, one control that opens popup `menu`, a toggle
413/// where the menu opens so its icons line up under the button's.464/// lit while `on` as `tool_button`'s. Returns where the menu opens so its icons line up
465/// under the button's.
414pub fn menu_button(466pub fn menu_button(
415 ui: &mut Ui,467 ui: &mut Ui,
416 part: impl Hash,468 part: impl Hash,
417 icon: &'static [&'static str],469 icon: &'static [&'static str],
470 on: Option<bool>,
418 menu: Id,471 menu: Id,
419) -> Anchor {472) -> Anchor {
420 let theme = ui.theme.clone();473 let theme = ui.theme.clone();
...@@ -423,12 +476,15 @@ pub fn menu_button(...@@ -423,12 +476,15 @@ pub fn menu_button(
423 Spec {476 Spec {
424 flags: Flags::CLICKABLE,477 flags: Flags::CLICKABLE,
425 size: [px(TOOL + ARROW), px(TOOL)],478 size: [px(TOOL + ARROW), px(TOOL)],
426 fill: ui.popup_open(menu).then(|| theme.hover()),479 fill: (ui.popup_open(menu) || on == Some(true)).then(|| theme.hover()),
427 hover_fill: Some(theme.hover()),480 hover_fill: Some(theme.hover()),
428 radius: 4.0,481 radius: 4.0,
482 role: Some(Role::Button),
429 ..Spec::default()483 ..Spec::default()
430 },484 },
431 );485 );
486 opens(ui, id, menu);
487 press_state(ui, id, on);
432 for (part, icon, width, color) in [488 for (part, icon, width, color) in [
433 ("icon", icon, TOOL, theme.text),489 ("icon", icon, TOOL, theme.text),
434 ("arrow", CHEVRON, ARROW, theme.text_dim),490 ("arrow", CHEVRON, ARROW, theme.text_dim),
...@@ -455,27 +511,53 @@ pub fn menu_button(...@@ -455,27 +511,53 @@ pub fn menu_button(
455 Anchor::Below([left - shift, top, right - shift, bottom])511 Anchor::Below([left - shift, top, right - shift, bottom])
456}512}
457513
458/// A drop-down box `width` wide showing `text`.514/// A drop-down box `name`d, `width` wide, showing `text`, that opens popup `menu`; where
459pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>) -> Signal {515/// not `enabled` it shows faded and takes no clicks or focus, as `unavailable` buttons do.
516pub fn combo(
517 ui: &mut Ui,
518 part: impl Hash,
519 name: &str,
520 text: &str,
521 width: impl Into<Extent>,
522 menu: Id,
523 enabled: bool,
524) {
460 let theme = ui.theme.clone();525 let theme = ui.theme.clone();
526 let faded = |[red, green, blue, alpha]: [f32; 4]| {
527 [red, green, blue, if enabled { alpha } else { alpha * 0.35 }]
528 };
461 let id = ui.open(529 let id = ui.open(
462 part,530 part,
463 Spec {531 Spec {
464 flags: Flags::CLICKABLE,532 flags: if enabled {
533 Flags::CLICKABLE
534 } else {
535 Flags::default()
536 },
465 size: [width.into(), px(TOOL)],537 size: [width.into(), px(TOOL)],
466 fill: Some(theme.base),538 fill: Some(theme.base),
467 hover_border: Some(theme.accent),539 hover_border: enabled.then_some(theme.accent),
468 border: Some(theme.chip),540 border: Some(faded(theme.chip)),
469 radius: 4.0,541 radius: 4.0,
470 pad: [8.0, 0.0],542 pad: [8.0, 0.0],
543 role: Some(Role::ComboBox),
471 ..Spec::default()544 ..Spec::default()
472 },545 },
473 );546 );
547 opens(ui, id, menu);
548 if let Some(node) = ui.access(id) {
549 node.set_label(name);
550 node.set_value(text);
551 if !enabled {
552 node.set_disabled();
553 }
554 }
474 ui.leaf(555 ui.leaf(
475 "text",556 "text",
476 Spec {557 Spec {
477 size: [fill(), px(TOOL)],558 size: [fill(), px(TOOL)],
478 text: Some(text),559 text: Some(text),
560 color: Some(faded(theme.text)),
479 ..Spec::default()561 ..Spec::default()
480 },562 },
481 );563 );
...@@ -484,13 +566,15 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>)...@@ -484,13 +566,15 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>)
484 Spec {566 Spec {
485 size: [px(12.0), px(TOOL)],567 size: [px(12.0), px(TOOL)],
486 icon: Some(CHEVRON),568 icon: Some(CHEVRON),
487 color: Some(theme.text_dim),569 color: Some(faded(theme.text_dim)),
488 center: true,570 center: true,
489 ..Spec::default()571 ..Spec::default()
490 },572 },
491 );573 );
492 ui.close();574 ui.close();
493 ui.signal(id)575 if enabled && ui.signal(id).pressed {
576 ui.open_popup(menu);
577 }
494}578}
495579
496impl Ui {580impl Ui {
crates/ui/src/tests.rs+231-31
...@@ -326,6 +326,38 @@ fn clicks_use_the_previous_layout_and_hover_animates() {...@@ -326,6 +326,38 @@ fn clicks_use_the_previous_layout_and_hover_animates() {
326 assert!(!ui.wants_frame());326 assert!(!ui.wants_frame());
327}327}
328328
329/// Two boxes swapping places under a still pointer, as dropped tabs reorder, pass the hover
330/// to the one that arrives.
331#[test]
332fn a_box_moving_under_a_still_pointer_takes_the_hover() {
333 let build = |ui: &mut Ui, swapped: bool| {
334 let mut hovered = [false; 2];
335 frame(ui, |ui| {
336 for (index, hover) in hovered.iter_mut().enumerate() {
337 let x = if (index == 0) != swapped { 0.0 } else { 100.0 };
338 *hover = ui
339 .leaf(
340 index,
341 Spec {
342 flags: Flags::CLICKABLE | Flags::FLOAT,
343 size: [px(50.0), px(20.0)],
344 position: [x, 0.0],
345 ..Spec::default()
346 },
347 )
348 .hovered;
349 }
350 });
351 hovered
352 };
353 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
354 build(&mut ui, false);
355 ui.event(Event::PointerMoved([10.0, 10.0]));
356 assert_eq!(build(&mut ui, false), [true, false]);
357 build(&mut ui, true);
358 assert_eq!(build(&mut ui, true), [false, true]);
359}
360
329/// A click whose press and release arrive together starts an animation that a box built361/// A click whose press and release arrive together starts an animation that a box built
330/// earlier in the frame reads: the frame that handled the click asks for the next one.362/// earlier in the frame reads: the frame that handled the click asks for the next one.
331#[test]363#[test]
...@@ -434,12 +466,16 @@ fn custom_boxes_receive_their_events_and_a_leave_when_the_pointer_moves_off() {...@@ -434,12 +466,16 @@ fn custom_boxes_receive_their_events_and_a_leave_when_the_pointer_moves_off() {
434 ui.cursor().is_none(),466 ui.cursor().is_none(),
435 "the host picks the cursor over its box"467 "the host picks the cursor over its box"
436 );468 );
469 ui.event(Event::Pressure(Some(0.5)));
437 ui.event(Event::PointerMoved([350.0, 20.0]));470 ui.event(Event::PointerMoved([350.0, 20.0]));
438 let signals = build(&mut ui);471 let signals = build(&mut ui);
439 assert_eq!(472 assert_eq!(
440 signals[0].events,473 signals[0].events,
441 [Event::PointerMoved([350.0, 20.0])],474 [
442 "a drag keeps its box"475 Event::Pressure(Some(0.5)),
476 Event::PointerMoved([350.0, 20.0])
477 ],
478 "a drag keeps its box, and the pen's pressure goes with it"
443 );479 );
444 ui.event(Event::Button {480 ui.event(Event::Button {
445 button: MouseButton::Left,481 button: MouseButton::Left,
...@@ -1299,9 +1335,9 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {...@@ -1299,9 +1335,9 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {
1299 let build = |ui: &mut Ui| {1335 let build = |ui: &mut Ui| {
1300 let mut chosen = None;1336 let mut chosen = None;
1301 frame(ui, |ui| {1337 frame(ui, |ui| {
1302 chosen = popup::palette(ui, palette, &commands, "Search commands")1338 chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands")
1303 });1339 });
1304 chosen1340 chosen.map(|(_, index)| index)
1305 };1341 };
1306 build(&mut ui);1342 build(&mut ui);
1307 ui.open_popup(palette);1343 ui.open_popup(palette);
...@@ -1320,11 +1356,42 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {...@@ -1320,11 +1356,42 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {
1320 );1356 );
1321}1357}
13221358
1359#[test]
1360fn a_prefix_typed_in_the_palette_switches_what_it_lists() {
1361 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1362 let palette = Id::ROOT.child("palette");
1363 let [commands, places] = [items(), fonts()];
1364 let build = |ui: &mut Ui| {
1365 let mut chosen = None;
1366 frame(ui, |ui| {
1367 let modes: [(&str, &[popup::Item]); 2] = [(">", &commands), ("", &places)];
1368 chosen = popup::palette(ui, palette, &modes, "Go to");
1369 });
1370 chosen
1371 };
1372 build(&mut ui);
1373 popup::open_with(&mut ui, palette, ">");
1374 build(&mut ui);
1375 ui.event(typed("sel"));
1376 ui.event(key(NamedKey::Enter));
1377 assert_eq!(build(&mut ui), Some((0, 3)), "typed after the prefix");
1378 popup::open_with(&mut ui, palette, ">");
1379 build(&mut ui);
1380 ui.event(key(NamedKey::Backspace));
1381 build(&mut ui);
1382 assert_eq!(popup::query(&ui, palette), Some(""));
1383 ui.event(typed("tnr"));
1384 ui.event(key(NamedKey::Enter));
1385 assert_eq!(build(&mut ui), Some((1, 42)), "back among the places");
1386}
1387
1323#[test]1388#[test]
1324fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {1389fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
1325 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1390 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1326 let grid = Id::ROOT.child("colours");1391 let grid = Id::ROOT.child("colours");
1327 let swatches: Vec<[f32; 4]> = (0..6).map(|index| [index as f32, 0.0, 0.0, 1.0]).collect();1392 let swatches: Vec<([f32; 4], &str)> = (0..6)
1393 .map(|index| ([index as f32, 0.0, 0.0, 1.0], ""))
1394 .collect();
1328 let build = |ui: &mut Ui| {1395 let build = |ui: &mut Ui| {
1329 let mut chosen = None;1396 let mut chosen = None;
1330 frame(ui, |ui| {1397 frame(ui, |ui| {
...@@ -1344,7 +1411,7 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {...@@ -1344,7 +1411,7 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
1344 ] {1411 ] {
1345 ui.event(key(named));1412 ui.event(key(named));
1346 }1413 }
1347 assert_eq!(build(&mut ui), Some(Some(swatches[4])));1414 assert_eq!(build(&mut ui), Some(Some(swatches[4].0)));
1348 ui.open_popup(grid);1415 ui.open_popup(grid);
1349 for _ in 0..40 {1416 for _ in 0..40 {
1350 build(&mut ui);1417 build(&mut ui);
...@@ -1423,6 +1490,64 @@ fn popups_opened_within_a_popup_stay_above_it() {...@@ -1423,6 +1490,64 @@ fn popups_opened_within_a_popup_stay_above_it() {
1423 assert!(ui.popup_open(menu_id()) && !ui.popup_open(submenu));1490 assert!(ui.popup_open(menu_id()) && !ui.popup_open(submenu));
1424}1491}
14251492
1493#[test]
1494fn a_submenu_opens_beside_its_row_once_the_pointer_rests_and_closes_with_its_choice() {
1495 let submenu = Id::ROOT.child("submenu");
1496 let mut entries = items();
1497 entries[1].submenu = true;
1498 let build = |ui: &mut Ui| {
1499 let mut chosen = None;
1500 frame(ui, |ui| {
1501 chosen = popup::menu(ui, menu_id(), BELOW, &entries, None);
1502 popup::submenus(ui, menu_id(), &entries, |item| {
1503 (item == 1).then_some(submenu)
1504 });
1505 popup::menu(ui, submenu, BELOW, &items(), None);
1506 });
1507 chosen
1508 };
1509 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1510 build(&mut ui);
1511 ui.open_popup(menu_id());
1512 for _ in 0..40 {
1513 build(&mut ui);
1514 }
1515 ui.event(Event::PointerMoved(row(&ui, 1)));
1516 for _ in 0..10 {
1517 build(&mut ui);
1518 }
1519 assert!(!ui.popup_open(submenu), "not while the pointer passes");
1520 for _ in 0..30 {
1521 build(&mut ui);
1522 }
1523 assert!(ui.popup_open(submenu), "once it rests");
1524 let [menu, beside] = [menu_id(), submenu].map(|id| ui.rect(id).unwrap());
1525 assert!(
1526 beside[0] >= menu[2] - 1.0,
1527 "beside the menu, not below the anchor"
1528 );
1529 ui.event(key(NamedKey::Escape));
1530 for _ in 0..40 {
1531 build(&mut ui);
1532 }
1533 assert!(
1534 ui.popup_open(menu_id()) && !ui.popup_open(submenu),
1535 "Escape closes only the submenu, which stays shut while the pointer rests"
1536 );
1537 let point = row(&ui, 1);
1538 click(&mut ui, point);
1539 assert_eq!(build(&mut ui), None, "a click opens it, not a choice");
1540 build(&mut ui);
1541 assert!(ui.popup_open(menu_id()) && ui.popup_open(submenu));
1542 let first = ui.rect(submenu.child("rows").child(0_u64)).unwrap();
1543 click(&mut ui, [first[0] + 4.0, first[1] + 4.0]);
1544 build(&mut ui);
1545 assert!(
1546 !ui.popup_open(menu_id()) && !ui.popup_open(submenu),
1547 "a choice ends both"
1548 );
1549}
1550
1426/// Keys in order, counting the lookups a list makes.1551/// Keys in order, counting the lookups a list makes.
1427struct Keyed {1552struct Keyed {
1428 keys: Vec<u64>,1553 keys: Vec<u64>,
...@@ -1635,6 +1760,39 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {...@@ -1635,6 +1760,39 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {
1635 );1760 );
1636}1761}
16371762
1763#[test]
1764fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() {
1765 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();
1766 let rows = menu_id().child("rows");
1767 for picker in [false, true] {
1768 let items: Vec<_> = names
1769 .iter()
1770 .enumerate()
1771 .map(|(index, text)| popup::Item {
1772 text,
1773 checked: Some(index == 30),
1774 current: picker && index == 30,
1775 ..popup::Item::default()
1776 })
1777 .collect();
1778 let build = |ui: &mut Ui| {
1779 frame(ui, |ui| {
1780 popup::menu(ui, menu_id(), BELOW, &items, None);
1781 })
1782 };
1783 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1784 build(&mut ui);
1785 ui.open_popup(menu_id());
1786 for _ in 0..3 {
1787 build(&mut ui);
1788 }
1789 assert_eq!(ui.popups[0].highlight, picker.then_some(30));
1790 let top = ui.rect(rows).unwrap()[1];
1791 let first = ui.rect(rows.child(0u64)).map(|rect| rect[1]);
1792 assert_eq!(first == Some(top), !picker, "{first:?} under {top}");
1793 }
1794}
1795
1638const FONTS: [&str; 46] = [1796const FONTS: [&str; 46] = [
1639 "American Typewriter",1797 "American Typewriter",
1640 "Andale Mono",1798 "Andale Mono",
...@@ -1764,7 +1922,7 @@ fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {...@@ -1764,7 +1922,7 @@ fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {
1764}1922}
17651923
1766#[test]1924#[test]
1767fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {1925fn a_filtered_menu_shows_its_new_results_at_once_from_the_top() {
1768 let fonts = fonts();1926 let fonts = fonts();
1769 let build = |ui: &mut Ui| {1927 let build = |ui: &mut Ui| {
1770 frame(ui, |ui| {1928 frame(ui, |ui| {
...@@ -1777,35 +1935,35 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {...@@ -1777,35 +1935,35 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
1777 for _ in 0..12 {1935 for _ in 0..12 {
1778 build(&mut ui);1936 build(&mut ui);
1779 }1937 }
1780 let results = menu_id().child("results");1938 let results = menu_id().child("rows");
1781 let height = |ui: &Ui| {1939 let height = |ui: &Ui| {
1782 let rect = ui.rect(results).unwrap();1940 let rect = ui.rect(results).unwrap();
1783 rect[3] - rect[1]1941 rect[3] - rect[1]
1784 };1942 };
1785 let tall = height(&ui);
1786 assert!(1943 assert!(
1787 !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW,1944 !ui.wants_frame() && height(&ui) > 5.0 * popup::MENU_ROW,
1788 "open at its full height"1945 "open at its full height"
1789 );1946 );
1790 ui.event(typed("tnr"));1947 ui.event(key(NamedKey::End));
1791 build(&mut ui);1948 for _ in 0..40 {
1792 build(&mut ui);1949 build(&mut ui);
1793 let row = ui.rect(menu_id().child("rows").child(42_u64)).unwrap();1950 }
1951 // Each query shows its results where they settle in the frame it is typed.
1952 for query in ["t", "nr"] {
1953 ui.event(typed(query));
1954 build(&mut ui);
1955 let shown = [ui.rect(results), ui.rect(results.child(42_u64))];
1956 build(&mut ui);
1957 assert!(!ui.wants_frame(), "nothing eases");
1958 assert_eq!([ui.rect(results), ui.rect(results.child(42_u64))], shown);
1959 }
1960 let row = ui.rect(results.child(42_u64)).unwrap();
1794 assert_eq!(1961 assert_eq!(
1795 row[1],1962 row[1],
1796 ui.rect(results).unwrap()[1],1963 ui.rect(results).unwrap()[1],
1797 "Times New Roman at the top"1964 "Times New Roman at the top"
1798 );1965 );
1799 let easing = height(&ui);
1800 assert!(easing > popup::MENU_ROW && easing < tall, "{easing}");
1801 let mut frames = 0;
1802 while ui.wants_frame() {
1803 build(&mut ui);
1804 frames += 1;
1805 assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row));
1806 }
1807 assert_eq!(height(&ui), popup::MENU_ROW);1966 assert_eq!(height(&ui), popup::MENU_ROW);
1808 assert!(frames <= 10, "settles in {frames} frames");
1809}1967}
18101968
1811/// A column `width` wide holding one label: its box, and the label as laid out.1969/// A column `width` wide holding one label: its box, and the label as laid out.
...@@ -1975,8 +2133,8 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() {...@@ -1975,8 +2133,8 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() {
1975 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15);2133 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15);
1976 assert_eq!(2134 assert_eq!(
1977 first.pivot,2135 first.pivot,
1978 [200.0, 125.0],2136 [200.0, 300.0 / 8.0],
1979 "the dialog swings from its top centre"2137 "the dialog swings from its top centre, an eighth down"
1980 );2138 );
1981 for _ in 0..16 {2139 for _ in 0..16 {
1982 frame(&mut ui, build);2140 frame(&mut ui, build);
...@@ -2070,9 +2228,15 @@ fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported {...@@ -2070,9 +2228,15 @@ fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported {
2070 },2228 },
2071 );2229 );
2072 let combo = ui.id("combo");2230 let combo = ui.id("combo");
2073 if shell::combo(ui, "combo", "Light", 120.0).pressed {2231 shell::combo(
2074 ui.open_popup(Id::ROOT.child("schemes"));2232 ui,
2075 }2233 "combo",
2234 "Color scheme",
2235 "Light",
2236 120.0,
2237 Id::ROOT.child("schemes"),
2238 true,
2239 );
2076 let items = ["System", "Light", "Dark"].map(|text| popup::Item {2240 let items = ["System", "Light", "Dark"].map(|text| popup::Item {
2077 text,2241 text,
2078 ..popup::Item::default()2242 ..popup::Item::default()
...@@ -2126,7 +2290,7 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl...@@ -2126,7 +2290,7 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl
2126 let mut chosen = None;2290 let mut chosen = None;
2127 let mut build = |ui: &mut Ui| {2291 let mut build = |ui: &mut Ui| {
2128 frame(ui, |ui| {2292 frame(ui, |ui| {
2129 shell::combo(ui, "combo", "Calibri", 120.0);2293 shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true);
2130 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());2294 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
2131 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));2295 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));
2132 });2296 });
...@@ -2235,7 +2399,7 @@ fn split_frame(ui: &mut Ui) -> Signal {...@@ -2235,7 +2399,7 @@ fn split_frame(ui: &mut Ui) -> Signal {
2235 ..Spec::default()2399 ..Spec::default()
2236 },2400 },
2237 );2401 );
2238 signal = shell::split_button(ui, "split", CHECK_ICON, None, false, split_menu());2402 signal = shell::split_button(ui, "split", "Split", CHECK_ICON, None, None, split_menu());
2239 let item = popup::Item {2403 let item = popup::Item {
2240 text: "Item",2404 text: "Item",
2241 ..Default::default()2405 ..Default::default()
...@@ -2331,7 +2495,7 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {...@@ -2331,7 +2495,7 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {
2331 ..Spec::default()2495 ..Spec::default()
2332 },2496 },
2333 );2497 );
2334 let anchor = shell::menu_button(ui, "align", CHECK_ICON, menu_id());2498 let anchor = shell::menu_button(ui, "align", CHECK_ICON, None, menu_id());
2335 popup::menu(ui, menu_id(), anchor, &items, None);2499 popup::menu(ui, menu_id(), anchor, &items, None);
2336 ui.close();2500 ui.close();
2337 });2501 });
...@@ -2543,3 +2707,39 @@ fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() {...@@ -2543,3 +2707,39 @@ fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() {
2543 assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2]));2707 assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2]));
2544 assert!(!ui.popup_open(picker));2708 assert!(!ui.popup_open(picker));
2545}2709}
2710
2711#[test]
2712fn password_fields_edit_the_characters_behind_their_bullets() {
2713 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
2714 let mut text = String::new();
2715 let edit = |ui: &mut Ui, text: &mut String| {
2716 frame(ui, |ui| {
2717 password_field(
2718 ui,
2719 field(),
2720 text,
2721 "",
2722 Spec {
2723 size: [px(200.0), px(26.0)],
2724 pad: [6.0, 0.0],
2725 ..Spec::default()
2726 },
2727 );
2728 })
2729 };
2730 edit(&mut ui, &mut text);
2731 ui.set_focus(Some(field()));
2732 for typed_text in ["p", "ä", "ß"] {
2733 ui.event(typed(typed_text));
2734 }
2735 edit(&mut ui, &mut text);
2736 ui.event(key(NamedKey::ArrowLeft));
2737 ui.event(key(NamedKey::ArrowLeft));
2738 ui.event(typed("x"));
2739 ui.event(key(NamedKey::Delete));
2740 edit(&mut ui, &mut text);
2741 assert_eq!(text, "pxß");
2742 ui.event(key(NamedKey::Backspace));
2743 edit(&mut ui, &mut text);
2744 assert_eq!(text, "pß");
2745}
crates/ui/src/text.rs+12-8
...@@ -47,17 +47,12 @@ impl Texts {...@@ -47,17 +47,12 @@ impl Texts {
47 .context47 .context
48 .ranged_builder(&mut self.fonts, text, 1.0, false);48 .ranged_builder(&mut self.fonts, text, 1.0, false);
49 // Fontconfig configurations without a system-ui alias still have a sans-serif.49 // Fontconfig configurations without a system-ui alias still have a sans-serif.
50 // Before 10.10 macOS's interface font is Lucida Grande, which fontique's system-ui50 let system =
51 // doesn't name.51 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);
52 let system = [
53 Some(FontFamilyName::Generic(GenericFamily::SystemUi)),
54 cfg!(target_os = "macos").then(|| FontFamilyName::named("Lucida Grande")),
55 Some(FontFamilyName::Generic(GenericFamily::SansSerif)),
56 ];
57 let families: Vec<_> = font52 let families: Vec<_> = font
58 .into_iter()53 .into_iter()
59 .map(FontFamilyName::named)54 .map(FontFamilyName::named)
60 .chain(system.into_iter().flatten())55 .chain(system)
61 .collect();56 .collect();
62 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(57 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
63 families,58 families,
...@@ -99,6 +94,15 @@ impl Texts {...@@ -99,6 +94,15 @@ impl Texts {
99 self.label(&cut, size, *bold, font, frame)94 self.label(&cut, size, *bold, font, frame)
100 }95 }
10196
97 /// Makes `family` the interface's font, where fontique doesn't know the system's.
98 pub fn set_system_font(&mut self, family: &str) {
99 let collection = &mut self.fonts.collection;
100 if let Some(id) = collection.family_id(family) {
101 collection.set_generic_families(GenericFamily::SystemUi, std::iter::once(id));
102 self.cache.clear();
103 }
104 }
105
102 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {106 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
103 self.fonts.collection.register_fonts(107 self.fonts.collection.register_fonts(
104 data,108 data,
crates/ui/src/widgets.rs+72-10
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};1use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};
2use accesskit::Role;
2use draw::RasterImage;3use draw::RasterImage;
3use draw::edit::{self, Command, Movement};4use draw::edit::{self, Command, Movement};
4use parley::{5use parley::{
...@@ -25,6 +26,7 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {...@@ -25,6 +26,7 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {
25 radius: 4.0,26 radius: 4.0,
26 pad: [theme.font_size * 0.75, 0.0],27 pad: [theme.font_size * 0.75, 0.0],
27 center: true,28 center: true,
29 role: Some(Role::Button),
28 ..Spec::default()30 ..Spec::default()
29 };31 };
30 ui.leaf(part, spec)32 ui.leaf(part, spec)
...@@ -42,9 +44,13 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si...@@ -42,9 +44,13 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
42 flags: Flags::CLICKABLE,44 flags: Flags::CLICKABLE,
43 size: [crate::children(), px(height)],45 size: [crate::children(), px(height)],
44 gap: 8.0,46 gap: 8.0,
47 role: Some(Role::CheckBox),
45 ..Spec::default()48 ..Spec::default()
46 },49 },
47 );50 );
51 if let Some(node) = ui.access(id) {
52 node.set_toggled(checked.into());
53 }
48 // A box's signal is taken once, so its hover and click are read together.54 // A box's signal is taken once, so its hover and click are read together.
49 let signal = ui.signal(id);55 let signal = ui.signal(id);
50 ui.leaf(56 ui.leaf(
...@@ -303,6 +309,48 @@ pub fn text_field(...@@ -303,6 +309,48 @@ pub fn text_field(
303 placeholder: &str,309 placeholder: &str,
304 spec: Spec<'_>,310 spec: Spec<'_>,
305) -> Signal {311) -> Signal {
312 field(ui, id, text, placeholder, spec, false)
313}
314
315/// A text field for a password, showing and announcing a bullet for each character.
316pub fn password_field(
317 ui: &mut Ui,
318 id: Id,
319 text: &mut String,
320 placeholder: &str,
321 spec: Spec<'_>,
322) -> Signal {
323 field(ui, id, text, placeholder, spec, true)
324}
325
326fn field(
327 ui: &mut Ui,
328 id: Id,
329 text: &mut String,
330 placeholder: &str,
331 spec: Spec<'_>,
332 secret: bool,
333) -> Signal {
334 const BULLET: &str = "\u{2022}";
335 // The text laid out, and where its offsets fall in `text`: a secret shows bullets.
336 let masked = |text: &str| {
337 if secret {
338 BULLET.repeat(text.chars().count())
339 } else {
340 text.to_owned()
341 }
342 };
343 let to_text = |text: &str, index: usize| match secret {
344 true => (text.char_indices().map(|(at, _)| at))
345 .nth(index / BULLET.len())
346 .unwrap_or(text.len()),
347 false => index,
348 };
349 let to_shown = |text: &str, index: usize| match secret {
350 true => text[..index].chars().count() * BULLET.len(),
351 false => index,
352 };
353 let mut shown_text = masked(text);
306 let signal = ui.signal(id);354 let signal = ui.signal(id);
307 let pad = spec.pad[0];355 let pad = spec.pad[0];
308 let size = ui.theme.font_size;356 let size = ui.theme.font_size;
...@@ -312,13 +360,13 @@ pub fn text_field(...@@ -312,13 +360,13 @@ pub fn text_field(
312 .zip(ui.rect(id))360 .zip(ui.rect(id))
313 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)361 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)
314 .filter(|_| signal.pressed || signal.dragging);362 .filter(|_| signal.pressed || signal.dragging);
315 let (mut selection, mut press, select_all) = {363 let (mut selection, mut press, select) = {
316 let (selection, press, select_all) = ui.field(id);364 let (selection, press, select) = ui.field(id);
317 (*selection, *press, std::mem::take(select_all))365 (*selection, *press, select.take())
318 };366 };
319 let before = [selection.anchor(), selection.focus()];367 let before = [selection.anchor(), selection.focus()];
320 let (texts, frame) = ui.texts();368 let (texts, frame) = ui.texts();
321 let mut label = texts.label(text, size, false, None, frame);369 let mut label = texts.label(&shown_text, size, false, None, frame);
322 if let Some(x) = pointer {370 if let Some(x) = pointer {
323 let layout = &label.layout;371 let layout = &label.layout;
324 let unit = if signal.pressed { signal.unit } else { press.1 };372 let unit = if signal.pressed { signal.unit } else { press.1 };
...@@ -342,10 +390,10 @@ pub fn text_field(...@@ -342,10 +390,10 @@ pub fn text_field(
342 Selection::new(anchor, focus)390 Selection::new(anchor, focus)
343 };391 };
344 }392 }
345 selection = if select_all {393 selection = if let Some([anchor, focus]) = select {
346 Selection::new(394 Selection::new(
347 Cursor::from_byte_index(&label.layout, 0, Affinity::Downstream),395 Cursor::from_byte_index(&label.layout, anchor, Affinity::Downstream),
348 Cursor::from_byte_index(&label.layout, usize::MAX, Affinity::Upstream),396 Cursor::from_byte_index(&label.layout, focus, Affinity::Upstream),
349 )397 )
350 } else {398 } else {
351 selection.refresh(&label.layout)399 selection.refresh(&label.layout)
...@@ -419,11 +467,13 @@ pub fn text_field(...@@ -419,11 +467,13 @@ pub fn text_field(
419 }467 }
420 _ => continue,468 _ => continue,
421 };469 };
470 let range = to_text(text, range.start)..to_text(text, range.end);
422 text.replace_range(range.clone(), inserted);471 text.replace_range(range.clone(), inserted);
423 label = texts.label(text, size, false, None, frame);472 shown_text = masked(text);
473 label = texts.label(&shown_text, size, false, None, frame);
424 selection = Selection::from_byte_index(474 selection = Selection::from_byte_index(
425 &label.layout,475 &label.layout,
426 range.start + inserted.len(),476 to_shown(text, range.start + inserted.len()),
427 Affinity::Downstream,477 Affinity::Downstream,
428 );478 );
429 }479 }
...@@ -437,7 +487,7 @@ pub fn text_field(...@@ -437,7 +487,7 @@ pub fn text_field(
437 let shown = if text.is_empty() {487 let shown = if text.is_empty() {
438 placeholder488 placeholder
439 } else {489 } else {
440 text.as_str()490 shown_text.as_str()
441 };491 };
442 let height = spec.size[1];492 let height = spec.size[1];
443 let backdrop = spec.fill.unwrap_or(theme.base);493 let backdrop = spec.fill.unwrap_or(theme.base);
...@@ -452,9 +502,20 @@ pub fn text_field(...@@ -452,9 +502,20 @@ pub fn text_field(
452 theme.text502 theme.text
453 }),503 }),
454 cursor: Some(CursorIcon::Text),504 cursor: Some(CursorIcon::Text),
505 role: spec.role.or(Some(if secret {
506 Role::PasswordInput
507 } else {
508 Role::TextInput
509 })),
455 ..spec510 ..spec
456 },511 },
457 );512 );
513 if let Some(node) = ui.access(id) {
514 node.set_value(shown_text.as_str());
515 if !placeholder.is_empty() {
516 node.set_placeholder(placeholder);
517 }
518 }
458 if signal.focused {519 if signal.focused {
459 let line = label.size[1];520 let line = label.size[1];
460 let top = match height.size {521 let top = match height.size {
...@@ -508,6 +569,7 @@ pub fn edit_key(key: &Key) -> edit::Key {...@@ -508,6 +569,7 @@ pub fn edit_key(key: &Key) -> edit::Key {
508 NamedKey::End => Edit::End,569 NamedKey::End => Edit::End,
509 NamedKey::PageUp => Edit::PageUp,570 NamedKey::PageUp => Edit::PageUp,
510 NamedKey::PageDown => Edit::PageDown,571 NamedKey::PageDown => Edit::PageDown,
572 NamedKey::F7 => Edit::F7,
511 NamedKey::F11 => Edit::F11,573 NamedKey::F11 => Edit::F11,
512 NamedKey::Alt574 NamedKey::Alt
513 | NamedKey::AltGraph575 | NamedKey::AltGraph
platform/snow-leopard/Cargo.lock+23
...@@ -18,6 +18,15 @@ version = "0.1.10"...@@ -18,6 +18,15 @@ version = "0.1.10"
18source = "registry+https://github.com/rust-lang/crates.io-index"18source = "registry+https://github.com/rust-lang/crates.io-index"
19checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"19checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
2020
21[[package]]
22name = "accesskit"
23version = "0.25.1"
24source = "registry+https://github.com/rust-lang/crates.io-index"
25checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
26dependencies = [
27 "uuid",
28]
29
21[[package]]30[[package]]
22name = "adler2"31name = "adler2"
23version = "2.0.1"32version = "2.0.1"
...@@ -116,6 +125,12 @@ version = "1.5.1"...@@ -116,6 +125,12 @@ version = "1.5.1"
116source = "registry+https://github.com/rust-lang/crates.io-index"125source = "registry+https://github.com/rust-lang/crates.io-index"
117checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"126checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
118127
128[[package]]
129name = "base64"
130version = "0.22.1"
131source = "registry+https://github.com/rust-lang/crates.io-index"
132checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
133
119[[package]]134[[package]]
120name = "bit-set"135name = "bit-set"
121version = "0.10.0"136version = "0.10.0"
...@@ -413,6 +428,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"...@@ -413,6 +428,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
413name = "draw"428name = "draw"
414version = "0.1.0"429version = "0.1.0"
415dependencies = [430dependencies = [
431 "base64",
416 "bytemuck",432 "bytemuck",
417 "image",433 "image",
418 "linebender_resource_handle",434 "linebender_resource_handle",
...@@ -2301,6 +2317,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"...@@ -2301,6 +2317,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
2301name = "ui"2317name = "ui"
2302version = "0.1.0"2318version = "0.1.0"
2303dependencies = [2319dependencies = [
2320 "accesskit",
2304 "draw",2321 "draw",
2305 "nucleo-matcher",2322 "nucleo-matcher",
2306 "parley",2323 "parley",
...@@ -2331,6 +2348,12 @@ version = "1.0.4"...@@ -2331,6 +2348,12 @@ version = "1.0.4"
2331source = "registry+https://github.com/rust-lang/crates.io-index"2348source = "registry+https://github.com/rust-lang/crates.io-index"
2332checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"2349checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
23332350
2351[[package]]
2352name = "uuid"
2353version = "1.26.1"
2354source = "registry+https://github.com/rust-lang/crates.io-index"
2355checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
2356
2334[[package]]2357[[package]]
2335name = "version_check"2358name = "version_check"
2336version = "0.9.5"2359version = "0.9.5"
platform/snow-leopard/cargo.sh+5-1
...@@ -11,11 +11,15 @@ shift...@@ -11,11 +11,15 @@ shift
11runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12)11runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12)
12# The deployment target is for cc-rs (bundled SQLite); rustc warns that it ignores it.12# The deployment target is for cc-rs (bundled SQLite); rustc warns that it ignores it.
13# rustc would infer ld64 from the name `link.sh`; it is a C compiler driver.13# rustc would infer ld64 from the name `link.sh`; it is a C compiler driver.
14# 10.6's EVFILT_USER drops mio's wakes, so Tokio wakes its I/O thread through a pipe.
15# objc2's debug builds check declared classes against today's SDKs, whose protocols have
16# methods 10.6's lack (winit's window delegate declares 10.7's), so they stay off.
14CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \17CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \
15SNOW_LEOPARD_SDK=$sdk \18SNOW_LEOPARD_SDK=$sdk \
16TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \19TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \
17MACOSX_DEPLOYMENT_TARGET=10.6 \20MACOSX_DEPLOYMENT_TARGET=10.6 \
18exec cargo +nightly -Zbuild-std=std,panic_unwind -Zjson-target-spec \21exec cargo +nightly -Zbuild-std=std,panic_unwind -Zjson-target-spec \
19 --config "target.$target.linker='$here/link.sh'" \22 --config "target.$target.linker='$here/link.sh'" \
20 --config "target.$target.rustflags=['-C','linker-flavor=darwin-cc','-C','metadata=rt-$runtime']" \23 --config "target.$target.rustflags=['-C','linker-flavor=darwin-cc','-C','metadata=rt-$runtime','--cfg','mio_unsupported_force_waker_pipe']" \
24 --config "profile.dev.package.objc2.debug-assertions=false" \
21 "$command" --target "$here/$target.json" "$@"25 "$command" --target "$here/$target.json" "$@"
platform/snow-leopard/console/art.m created+87
...@@ -0,0 +1,87 @@
1// Renders the pieces of 10.6's chrome that the Snow Leopard `--screenshot` samples, into
2// DIR as PNGs: the window buttons, and scrollers with their knob and without, each for a
3// key window and for another. `remote.sh art` fetches them into crates/snowbound/assets/snow-leopard.
4#import <AppKit/AppKit.h>
5#import <objc/runtime.h>
6
7@interface KeyWindow : NSWindow
8@end
9@implementation KeyWindow
10- (BOOL)isKeyWindow { return YES; }
11- (BOOL)isMainWindow { return YES; }
12@end
13
14// A scroller's track and arrows alone.
15@interface Track : NSScroller
16@end
17@implementation Track
18- (void)drawKnob {}
19@end
20
21static NSString *dir;
22
23static void save(NSView *view, NSRect rect, NSString *name) {
24 NSBitmapImageRep *rep = [view bitmapImageRepForCachingDisplayInRect:rect];
25 [view cacheDisplayInRect:rect toBitmapImageRep:rep];
26 [[rep representationUsingType:NSPNGFileType properties:nil]
27 writeToFile:[dir stringByAppendingPathComponent:name] atomically:YES];
28}
29
30static NSWindow *window(BOOL key) {
31 NSWindow *window = [[(key ? [KeyWindow class] : [NSWindow class]) alloc]
32 initWithContentRect:NSMakeRect(-10000, -10000, 500, 500)
33 styleMask:NSBorderlessWindowMask
34 backing:NSBackingStoreBuffered
35 defer:NO];
36 [window setOpaque:NO];
37 [window setBackgroundColor:[NSColor clearColor]];
38 return window;
39}
40
41int main(int argc, char **argv) {
42 NSAutoreleasePool *pool = [NSAutoreleasePool new];
43 if (argc != 2) {
44 fprintf(stderr, "usage: art DIR\n");
45 return 2;
46 }
47 [NSApplication sharedApplication];
48 dir = [NSString stringWithUTF8String:argv[1]];
49 [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:NULL];
50 NSUInteger style = NSTitledWindowMask | NSClosableWindowMask | NSMiniaturizableWindowMask
51 | NSResizableWindowMask | NSTexturedBackgroundWindowMask;
52 for (int key = 0; key < 2; key++) {
53 NSString *state = key ? @"key" : @"other";
54 NSView *content = [window(key) contentView];
55 // Close, minimize and zoom, side by side at their own size.
56 CGFloat x = 0, height = 0;
57 for (NSWindowButton kind = NSWindowCloseButton; kind <= NSWindowZoomButton; kind++) {
58 NSButton *button = [NSWindow standardWindowButton:kind forStyleMask:style];
59 [button setFrameOrigin:NSMakePoint(x, 0)];
60 [content addSubview:button];
61 x += [button frame].size.width;
62 height = [button frame].size.height;
63 }
64 save(content, NSMakeRect(0, 0, x, height), [NSString stringWithFormat:@"buttons-%@.png", state]);
65 [[content subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)];
66 for (int vertical = 0; vertical < 2; vertical++) {
67 NSString *axis = vertical ? @"vertical" : @"horizontal";
68 CGFloat width = [NSScroller scrollerWidth];
69 NSRect frame = vertical ? NSMakeRect(0, 0, width, 400) : NSMakeRect(0, 0, 400, width);
70 NSScroller *track = [[Track alloc] initWithFrame:frame];
71 [track setEnabled:YES];
72 [track setKnobProportion:0.25];
73 [content addSubview:track];
74 save(track, [track bounds], [NSString stringWithFormat:@"scroller-%@-track-%@.png", axis, state]);
75 [track removeFromSuperview];
76 NSScroller *scroller = [[NSScroller alloc] initWithFrame:frame];
77 [scroller setEnabled:YES];
78 [scroller setKnobProportion:0.25];
79 [scroller setDoubleValue:0.5];
80 [content addSubview:scroller];
81 save(scroller, [scroller bounds], [NSString stringWithFormat:@"scroller-%@-%@.png", axis, state]);
82 [scroller removeFromSuperview];
83 }
84 }
85 [pool drain];
86 return 0;
87}
platform/snow-leopard/console/screen.c+35-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1// Writes the main display to a PNG, for a session reached over ssh, where1// Writes the main display, or with OWNER the first window of the app of that name with
2// its shadow on transparency, to a PNG, for a session reached over ssh, where
2// screencapture exits 0 without writing a file. Wakes a sleeping display first: asleep,3// screencapture exits 0 without writing a file. Wakes a sleeping display first: asleep,
3// it shows its last frame.4// it shows its last frame.
4#include <ApplicationServices/ApplicationServices.h>5#include <ApplicationServices/ApplicationServices.h>
...@@ -7,16 +8,45 @@...@@ -7,16 +8,45 @@
7#include <string.h>8#include <string.h>
8#include <unistd.h>9#include <unistd.h>
910
11static CGWindowID window_of(const char *owner) {
12 CFArrayRef windows = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
13 CFStringRef name = CFStringCreateWithCString(NULL, owner, kCFStringEncodingUTF8);
14 CGWindowID found = 0;
15 for (CFIndex i = 0; i < CFArrayGetCount(windows) && !found; i++) {
16 CFDictionaryRef window = CFArrayGetValueAtIndex(windows, i);
17 CFStringRef of = CFDictionaryGetValue(window, kCGWindowOwnerName);
18 CFNumberRef layer = CFDictionaryGetValue(window, kCGWindowLayer);
19 int level = -1;
20 if (layer) CFNumberGetValue(layer, kCFNumberIntType, &level);
21 if (of && level == 0 && CFStringCompare(of, name, 0) == kCFCompareEqualTo)
22 CFNumberGetValue(CFDictionaryGetValue(window, kCGWindowNumber), kCFNumberIntType, &found);
23 }
24 CFRelease(name);
25 CFRelease(windows);
26 return found;
27}
28
10int main(int argc, char **argv) {29int main(int argc, char **argv) {
11 if (argc != 2) {30 if (argc != 2 && argc != 3) {
12 fprintf(stderr, "usage: screen OUT.png\n");31 fprintf(stderr, "usage: screen OUT.png [OWNER]\n");
13 return 2;32 return 2;
14 }33 }
15 UpdateSystemActivity(UsrActivity);34 UpdateSystemActivity(UsrActivity);
16 sleep(2);35 sleep(2);
17 CGImageRef image = CGDisplayCreateImage(CGMainDisplayID());36 CGImageRef image;
37 if (argc == 3) {
38 CGWindowID window = window_of(argv[2]);
39 if (!window) {
40 fprintf(stderr, "screen: no window of %s\n", argv[2]);
41 return 1;
42 }
43 image = CGWindowListCreateImage(CGRectNull, kCGWindowListOptionIncludingWindow, window,
44 kCGWindowImageDefault);
45 } else {
46 image = CGDisplayCreateImage(CGMainDisplayID());
47 }
18 if (!image) {48 if (!image) {
19 fprintf(stderr, "screen: no image of the display\n");49 fprintf(stderr, "screen: no image\n");
20 return 1;50 return 1;
21 }51 }
22 CFURLRef url = CFURLCreateFromFileSystemRepresentation(NULL, (const UInt8 *)argv[1], strlen(argv[1]), false);52 CFURLRef url = CFURLCreateFromFileSystemRepresentation(NULL, (const UInt8 *)argv[1], strlen(argv[1]), false);
platform/snow-leopard/hello/src/main.rs+3-1
...@@ -13,6 +13,8 @@ fn main() {...@@ -13,6 +13,8 @@ fn main() {
13 let text = std::fs::read_to_string(&file).unwrap();13 let text = std::fs::read_to_string(&file).unwrap();
14 let meta = std::fs::metadata(&file).unwrap();14 let meta = std::fs::metadata(&file).unwrap();
15 let entries = std::fs::read_dir(&dir).unwrap().count();15 let entries = std::fs::read_dir(&dir).unwrap().count();
16 // 10.6 packs directory entries 4 bytes apart; debug builds check them for alignment.
17 let frameworks = std::fs::read_dir("/System/Library/Frameworks").map_or(0, Iterator::count);
16 std::fs::remove_dir_all(&dir).unwrap();18 std::fs::remove_dir_all(&dir).unwrap();
1719
18 let start = Instant::now();20 let start = Instant::now();
...@@ -33,7 +35,7 @@ fn main() {...@@ -33,7 +35,7 @@ fn main() {
33 map.insert("random-state", 1);35 map.insert("random-state", 1);
3436
35 let since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();37 let since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
36 println!("fs: {text:?} ({} bytes, {entries} entries)", meta.len());38 println!("fs: {text:?} ({} bytes, {entries} entries), {frameworks} frameworks", meta.len());
37 println!("tls: threads {counts:?}, main {}", COUNTER.with(Cell::get));39 println!("tls: threads {counts:?}, main {}", COUNTER.with(Cell::get));
38 println!("time: {}s since epoch, {:?} elapsed", since_epoch.as_secs(), start.elapsed());40 println!("time: {}s since epoch, {:?} elapsed", since_epoch.as_secs(), start.elapsed());
39 println!("args: {:?}, HOME={:?}", std::env::args().collect::<Vec<_>>(), std::env::var("HOME").ok());41 println!("args: {:?}, HOME={:?}", std::env::args().collect::<Vec<_>>(), std::env::var("HOME").ok());
platform/snow-leopard/link.sh+2
...@@ -20,6 +20,8 @@ for arg; do...@@ -20,6 +20,8 @@ for arg; do
20 # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas)20 # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas)
21 # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it.21 # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it.
22 Metal) arg=Foundation ;;22 Metal) arg=Foundation ;;
23 # 10.7's; rt/avfoundation.c stands in for what the app imports from them.
24 AVFoundation | CoreMedia) arg=Foundation ;;
23 esac25 esac
24 fi26 fi
25 [ "$prev" = -arch ] && arch=$arg27 [ "$prev" = -arch ] && arch=$arg
platform/snow-leopard/remote.sh+18-7
...@@ -7,12 +7,14 @@...@@ -7,12 +7,14 @@
7# remote.sh start BINARY [ARGS...] ship and leave running there, output in NAME.log;7# remote.sh start BINARY [ARGS...] ship and leave running there, output in NAME.log;
8# BINARY may be an .app bundle8# BINARY may be an .app bundle
9# remote.sh stop NAME end what start started (a bundle by its executable)9# remote.sh stop NAME end what start started (a bundle by its executable)
10# remote.sh screen LOCAL.png wake the display and capture it (screencapture10# remote.sh screen LOCAL.png [APP] wake the display and capture it, or APP's window with
11# writes nothing from ssh)11# its shadow (screencapture writes nothing from ssh)
12# remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES12# remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES
13# | type TEXT | key KEYCODE [command|shift|option|control]...13# | type TEXT | key KEYCODE [command|shift|option|control]...
14# remote.sh selectors BINARY selector-like names BINARY holds that nothing on14# remote.sh selectors BINARY selector-like names BINARY holds that nothing on
15# 10.6 implements15# 10.6 implements
16# remote.sh art fetch 10.6's window buttons and scroller pieces
17# into crates/snowbound/assets/snow-leopard
16# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so18# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
17# test binaries find fixtures by CARGO_MANIFEST_DIR19# test binaries find fixtures by CARGO_MANIFEST_DIR
18# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot)20# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot)
...@@ -37,10 +39,11 @@ ship() {...@@ -37,10 +39,11 @@ ship() {
37tool() {39tool() {
38 sdk=$here/../../target/snow-leopard/MacOSX10.6.sdk40 sdk=$here/../../target/snow-leopard/MacOSX10.6.sdk
39 out=$here/../../target/snow-leopard/$141 out=$here/../../target/snow-leopard/$1
40 if [ ! "$out" -nt "$here/console/$1.c" ]; then42 source=$(ls "$here/console/$1".[cm])
43 if [ ! "$out" -nt "$source" ]; then
41 clang -arch x86_64 -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \44 clang -arch x86_64 -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \
42 -framework ApplicationServices -framework CoreServices -lobjc \45 -framework AppKit -framework ApplicationServices -framework CoreServices -lobjc \
43 "$here/console/$1.c" -o "$out" 2>&1 | grep -v -e "MH_DYLIB_STUB" -e "no platform load" >&2 || true46 "$source" -o "$out" 2>&1 | grep -v -e "MH_DYLIB_STUB" -e "no platform load" >&2 || true
44 fi47 fi
45 ship "$out"48 ship "$out"
46}49}
...@@ -88,7 +91,8 @@ stop)...@@ -88,7 +91,8 @@ stop)
88 ;;91 ;;
89screen)92screen)
90 tool screen93 tool screen
91 ssh "$host" "$dir/screen $dir/screen.png && cat $dir/screen.png" > "$2"94 quote "${3:-}"
95 ssh "$host" "$dir/screen $dir/screen.png${3:+$quoted} && cat $dir/screen.png" > "$2"
92 ;;96 ;;
93input)97input)
94 shift98 shift
...@@ -101,6 +105,13 @@ selectors)...@@ -101,6 +105,13 @@ selectors)
101 strings -a "$2" | grep -E '^[a-z][a-z]+([A-Z][a-z0-9]+)*[A-Z]?[A-Za-z0-9]*(:([A-Za-z][A-Za-z0-9]*:)*)?$' |105 strings -a "$2" | grep -E '^[a-z][a-z]+([A-Z][a-z0-9]+)*[A-Z]?[A-Za-z0-9]*(:([A-Za-z][A-Za-z0-9]*:)*)?$' |
102 grep -E '[a-z]{2}[A-Z][a-z]|:$' | sort -u | ssh "$host" "$dir/selectors 2>/dev/null"106 grep -E '[a-z]{2}[A-Z][a-z]|:$' | sort -u | ssh "$host" "$dir/selectors 2>/dev/null"
103 ;;107 ;;
108art)
109 tool art
110 art=$here/../../crates/snowbound/assets/snow-leopard
111 mkdir -p "$art"
112 ssh "$host" "rm -rf $dir/pieces && $dir/art $dir/pieces 2>/dev/null && cd $dir/pieces && tar czf - *.png" |
113 tar xzf - -C "$art"
114 ;;
104mirror)115mirror)
105 shift116 shift
106 root=$(cd "$here/../.." && pwd)117 root=$(cd "$here/../.." && pwd)
...@@ -120,7 +131,7 @@ sh)...@@ -120,7 +131,7 @@ sh)
120 ssh "$host" "$@"131 ssh "$host" "$@"
121 ;;132 ;;
122*)133*)
123 sed -n '2,21s/^# \{0,1\}//p' "$0" >&2134 sed -n '2,23s/^# \{0,1\}//p' "$0" >&2
124 exit 2135 exit 2
125 ;;136 ;;
126esac137esac
platform/snow-leopard/rt/appkit.c+7
...@@ -22,6 +22,13 @@ const CFStringRef NSAccessibilityTabButtonSubrole = CFSTR("AXTabButton");...@@ -22,6 +22,13 @@ const CFStringRef NSAccessibilityTabButtonSubrole = CFSTR("AXTabButton");
22const CFStringRef NSAccessibilityTextAlignmentAttribute = CFSTR("AXTextAlignment");22const CFStringRef NSAccessibilityTextAlignmentAttribute = CFSTR("AXTextAlignment");
23const CFStringRef NSAccessibilityToggleSubrole = CFSTR("AXToggle");23const CFStringRef NSAccessibilityToggleSubrole = CFSTR("AXToggle");
2424
25// NSURL resource keys from 10.7 to 10.9 for iCloud files, which the app asks about; 10.6
26// knows none of them, so a file there reads as not in iCloud.
27const CFStringRef NSURLIsUbiquitousItemKey = CFSTR("NSURLIsUbiquitousItemKey");
28const CFStringRef NSURLUbiquitousItemDownloadingStatusKey = CFSTR("NSURLUbiquitousItemDownloadingStatusKey");
29const CFStringRef NSURLUbiquitousItemDownloadingStatusNotDownloaded =
30 CFSTR("NSURLUbiquitousItemDownloadingStatusNotDownloaded");
31
25extern void NSAccessibilityPostNotification(id element, CFStringRef notification);32extern void NSAccessibilityPostNotification(id element, CFStringRef notification);
2633
27void NSAccessibilityPostNotificationWithUserInfo(id element, CFStringRef notification, id info) {34void NSAccessibilityPostNotificationWithUserInfo(id element, CFStringRef notification, id info) {
platform/snow-leopard/rt/avfoundation.c created+34
...@@ -0,0 +1,34 @@
1// AVFoundation and Core Media names the app's recording and playback import, which
2// arrived in 10.7. 10.6 has none of their classes, so the app never reaches these: it
3// opens recordings in the system's player and says recording needs 10.7.
4#include <CoreFoundation/CoreFoundation.h>
5#include <stddef.h>
6
7const CFStringRef AVFormatIDKey = CFSTR("AVFormatIDKey");
8const CFStringRef AVSampleRateKey = CFSTR("AVSampleRateKey");
9const CFStringRef AVNumberOfChannelsKey = CFSTR("AVNumberOfChannelsKey");
10const CFStringRef AVLinearPCMBitDepthKey = CFSTR("AVLinearPCMBitDepthKey");
11const CFStringRef AVLinearPCMIsFloatKey = CFSTR("AVLinearPCMIsFloatKey");
12const CFStringRef AVLinearPCMIsBigEndianKey = CFSTR("AVLinearPCMIsBigEndianKey");
13const CFStringRef AVLinearPCMIsNonInterleaved = CFSTR("AVLinearPCMIsNonInterleaved");
14const CFStringRef AVMediaTypeAudio = CFSTR("soun");
15const CFStringRef AVMediaTypeVideo = CFSTR("vide");
16const CFStringRef AVCaptureSessionPreset320x240 = CFSTR("AVCaptureSessionPreset320x240");
17
18typedef struct {
19 long long value;
20 int timescale;
21 unsigned flags;
22 long long epoch;
23} CMTime;
24
25void *CMSampleBufferGetImageBuffer(void *sample) { return NULL; }
26void *CMSampleBufferGetDataBuffer(void *sample) { return NULL; }
27size_t CMBlockBufferGetDataLength(void *buffer) { return 0; }
28int CMBlockBufferCopyDataBytes(void *buffer, size_t offset, size_t length, void *destination) {
29 return -1;
30}
31CMTime CMSampleBufferGetPresentationTimeStamp(void *sample) {
32 CMTime invalid = {0, 0, 0, 0};
33 return invalid;
34}
platform/snow-leopard/rt/shims.c+26
...@@ -8,8 +8,10 @@...@@ -8,8 +8,10 @@
8#include <limits.h>8#include <limits.h>
9#include <math.h>9#include <math.h>
10#include <mach/mach_time.h>10#include <mach/mach_time.h>
11#include <pthread.h>
11#include <stdarg.h>12#include <stdarg.h>
12#include <stdint.h>13#include <stdint.h>
14#include <stdlib.h>
13#include <string.h>15#include <string.h>
14#include <sys/stat.h>16#include <sys/stat.h>
15#include <sys/time.h>17#include <sys/time.h>
...@@ -105,6 +107,30 @@ int fclonefileat(int fd, int dirfd, const char *to, int flags) {...@@ -105,6 +107,30 @@ int fclonefileat(int fd, int dirfd, const char *to, int flags) {
105107
106// Callers keep using `fd` (std's remove_dir_all passes it to openat/unlinkat), so the108// Callers keep using `fd` (std's remove_dir_all passes it to openat/unlinkat), so the
107// stream takes over that number rather than closing it.109// stream takes over that number rather than closing it.
110// 10.6 packs directory entries 4 bytes apart, and std reads the dirent's 64-bit inode
111// in place, which debug builds check for alignment. A misaligned entry is copied into a
112// per-thread aligned one, as good as the original until the next readdir.
113static pthread_key_t entry_key;
114static pthread_once_t entry_once = PTHREAD_ONCE_INIT;
115static void make_entry_key(void) { pthread_key_create(&entry_key, free); }
116
117struct dirent *readdir(DIR *dir) __asm("_readdir" __DARWIN_SUF_64_BIT_INO_T);
118struct dirent *readdir(DIR *dir) {
119 static __typeof__(readdir) *real_readdir;
120 if (!real_readdir) real_readdir = (__typeof__(readdir) *)dlsym(RTLD_NEXT, "readdir$INODE64");
121 struct dirent *entry = real_readdir(dir);
122 if (!entry || ((uintptr_t)entry & 7) == 0) return entry;
123 pthread_once(&entry_once, make_entry_key);
124 struct dirent *aligned = pthread_getspecific(entry_key);
125 if (!aligned) {
126 aligned = malloc(sizeof *aligned);
127 if (!aligned) return entry;
128 pthread_setspecific(entry_key, aligned);
129 }
130 memcpy(aligned, entry, entry->d_reclen < sizeof *aligned ? entry->d_reclen : sizeof *aligned);
131 return aligned;
132}
133
108DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03);134DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03);
109DIR *fdopendir(int fd) {135DIR *fdopendir(int fd) {
110 char path[PATH_MAX];136 char path[PATH_MAX];
tools/RELEASE.md created+111
...@@ -0,0 +1,111 @@
1# Releases and updates
2
3`tools/release.py` publishes the commit `main` points at, for every desktop
4platform, into Clover's NAS folder `/Volumes/clover/Documents/Public/Snowbound/`,
5which `https://file.paperclover.net/shr/snowbound/` serves read-only. The app
6checks that URL and updates itself.
7
8## Versions
9
10A build is named by its commit on `main`: the day the commit was made, in
11Los Angeles, and how many commits among it and its ancestors were made that
12day, itself included. Commit times are jj's committer timestamps. The fourth
13commit of 2026-09-29 is **Snowbound build 2026-09-29 revision 4**, written
14`2026-09-29-r4` in manifests and published in the folder `2026-09-29.r4/`. A
15build's version is compiled in from `SNOWBOUND_BUILD`; builds without it are
16development builds, which never update themselves.
17
18## The published folder
19
20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "linux-aarch64": ...}
222026-09-29.r10/
23 build.json version, commit, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex
25 Snowbound-2026-09-29-r10-macos-aarch64.zip
26 Snowbound-2026-09-29-r10-macos-10.6.zip
27 snowbound-2026-09-29-r10-linux-x86_64 the executable itself
28 snowbound-2026-09-29-r10-linux-aarch64
29```
30
31A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into
32place, and never changed or deleted. `latest.json` is replaced last, through a
33rename, and only moves a platform forward. It carries no signature: the trust is
34in the immutable `build.json`, whose version the app checks against the one it
35was pointed to, and a forged pointer can only name another signed build, which
36the app ignores unless it is newer than itself.
37
38## Signing
39
40Every `build.json` and archive is signed with the ed25519 release key in
41`~/.config/snowbound/release-key` (PKCS#8, mode 600, never in the repository).
42Its public half is `crates/snowbound/release-key.pub`, compiled into the app.
43`cargo run -p snowbound --example release_sign -- KEY FILE...` prints
44signatures and refuses a key that doesn't match `release-key.pub`;
45`release_sign new KEY` makes a new key. Replacing the key means shipping a
46build with the new public half, signed with the old key.
47
48The macOS app is signed with Clover's Developer ID Application certificate
49(team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is
50the account holder's legal name, which nothing here prints or stores. It gets
51hardened runtime, a secure timestamp, and the microphone and camera
52entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle
53stays unsigned, as it predates Developer ID. codesign fails with
54`errSecInternalComponent` where it can't ask to use the private key, as from an
55agent's shell; `security set-key-partition-list -S apple-tool:,apple:,codesign:
56-s -k PASSWORD ~/Library/Keychains/login.keychain-db` lets it sign without
57asking.
58
59Notarization runs when the keychain profile `snowbound` exists and signs in,
60and is skipped with a note otherwise. Make the profile once, with an App Store
61Connect API key (Users and Access, Integrations, Team Keys: a key with the
62Developer role; its `.p8`, key ID and issuer ID):
63
64```sh
65xcrun notarytool store-credentials snowbound --key AuthKey_KEYID.p8 --key-id KEYID --issuer ISSUER-UUID
66```
67
68or with an app-specific password from account.apple.com:
69`xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`.
70Until the app is notarized, a download opened in Finder needs Open from its
71context menu the first time; updates the app installs itself carry no
72quarantine and open directly.
73
74## Publishing
75
76```sh
77python3 tools/release.py # all four platforms
78python3 tools/release.py --ad-hoc # the macOS app without Developer ID
79python3 tools/release.py --dry-run # the same into a new temporary folder
80python3 tools/release.py --platforms macos-aarch64 linux-x86_64
81```
82
83The script refuses to run unless the working copy matches `main` (a dry run
84only warns), runs Clippy (workspace, and `snowbound` without default features)
85and the workspace tests, builds each platform with
86`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6) and
87`crates/snowbound/linux/package.sh` (taking only its executables), then checks
88the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and
89publishes as above. Run again for the same commit, it only brings
90`latest.json` up to date; a different commit that derives the same version is
91refused. The 10.6 build needs the SDK and nightly toolchain
92`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`.
93
94## In the app
95
96`update.rs` runs one thread. With automatic checks on (Options, General) and a
97published build, it checks shortly after launch and then daily, skipping while
98Work Offline is on; Check for Updates… (the app menu on macOS, the command
99palette elsewhere) checks at once and reports what it found. A check reads
100`latest.json`, then the named build's `build.json` and signature, and
101downloads the archive for this platform, verifying size, SHA-256 and
102signature. It stages the update beside the install, so the swap is a rename:
103the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
104executable into `.snowbound.update` next to it on Linux. The sync status icon gets a dot, and its popup offers
105Build Folder and Restart to Update.
106
107Restart to Update quits the app and starts the old executable with
108`--finish-update`, which waits for the app to exit, renames the new bundle (or
109executable) over the old one, and opens it. Where the install can't be
110written, and on Mac OS X 10.6, the popup and the check name the build and
111open its folder to download instead.
tools/canvas/README.md+4-4
...@@ -14,13 +14,13 @@ cargo test -p draw -- --ignored...@@ -14,13 +14,13 @@ cargo test -p draw -- --ignored
14cargo test -p canvas --features gpu gpu:: -- --ignored14cargo test -p canvas --features gpu gpu:: -- --ignored
15```15```
1616
17The builder ad-hoc signs and verifies the local bundle. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:17The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which Use iCloud Drive needs; without both, ad hoc. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:
1818
19```sh19```sh
20python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id dev.snowbound.review20python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review
21```21```
2222
23The explicit output path must not exist. Its filename supplies the bundle's display name; the default build continues to update `target/Snowbound.app`. Local ad-hoc signing does not provide a Developer ID or notarization.23The explicit output path must not exist. Its filename supplies the bundle's display name; the default build continues to update `target/Snowbound.app`. A separate identifier is signed ad hoc, without the container. Nothing here notarizes.
2424
25Control-Command-Space opens the macOS character picker for the active text field.25Control-Command-Space opens the macOS character picker for the active text field.
2626
...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,
5454
55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.
5656
57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.
5858
59```sh59```sh
60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one
tools/canvas/build_macos.py+92-7
...@@ -1,10 +1,13 @@...@@ -1,10 +1,13 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one."""2"""Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one."""
3import argparse3import argparse
4from datetime import datetime
4import json5import json
6import os
5from pathlib import Path7from pathlib import Path
6import platform8import platform
7import plistlib9import plistlib
10import re
8import shutil11import shutil
9import subprocess12import subprocess
10import tempfile13import tempfile
...@@ -12,15 +15,63 @@ import tempfile...@@ -12,15 +15,63 @@ import tempfile
12parser = argparse.ArgumentParser(description=__doc__)15parser = argparse.ArgumentParser(description=__doc__)
13parser.add_argument('--release', action='store_true')16parser.add_argument('--release', action='store_true')
14parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
15parser.add_argument('--bundle-id', help='Bundle identifier for the separate app')18parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")
16parser.add_argument('--snow-leopard', action='store_true',19parser.add_argument('--snow-leopard', action='store_true',
17 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')20 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')
21parser.add_argument('--sign-identity', metavar='SHA1',
22 help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise")
23parser.add_argument('--profile', type=Path,
24 help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise')
18args = parser.parse_args()25args = parser.parse_args()
19if bool(args.output) != bool(args.bundle_id):26if args.bundle_id and not args.output:
20 parser.error('Use --output and --bundle-id together.')27 parser.error('Use --bundle-id with --output.')
21if args.output and (args.output.suffix != '.app' or args.output.exists()):28if args.output and (args.output.suffix != '.app' or args.output.exists()):
22 parser.error('Choose a new output path ending in .app.')29 parser.error('Choose a new output path ending in .app.')
23root = Path(__file__).resolve().parents[2]30root = Path(__file__).resolve().parents[2]
31# The Apple Developer team and what is registered under it. With the team's Developer ID
32# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is
33# signed with hardened runtime and the iCloud container (Use iCloud Drive, NSUbiquitousContainers);
34# without them, ad hoc, and iCloud Drive works through folders the user picks.
35TEAM = '9R7DPNW28H'
36BUNDLE_ID = 'net.paperclover.snowbound'
37CONTAINER = 'iCloud.net.paperclover.snowbound'
38
39
40def developer_id():
41 """The team's Developer ID Application identity by SHA-1 hash. The listing names the
42 certificate's holder, so none of it is printed."""
43 listing = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'],
44 capture_output=True, text=True).stdout
45 for line in listing.splitlines():
46 if 'Developer ID Application:' in line and f'({TEAM})' in line:
47 found = re.search(r'\b[0-9A-F]{40}\b', line)
48 if found:
49 return found.group(0)
50 return None
51
52
53def profile_entitlements(path):
54 decoded = subprocess.run(['security', 'cms', '-D', '-i', str(path)], capture_output=True)
55 if decoded.returncode != 0:
56 return None
57 profile = plistlib.loads(decoded.stdout)
58 entitlements = profile.get('Entitlements', {})
59 fits = (entitlements.get('com.apple.application-identifier') == f'{TEAM}.{BUNDLE_ID}'
60 and CONTAINER in entitlements.get('com.apple.developer.icloud-container-identifiers', [])
61 and 'ProvisionedDevices' not in profile
62 and profile.get('ExpirationDate', datetime.max) > datetime.now())
63 return entitlements if fits else None
64
65
66def developer_id_profile():
67 """A Developer ID provisioning profile for the app and its iCloud container."""
68 folders = [Path.home() / 'Library/Developer/Xcode/UserData/Provisioning Profiles',
69 Path.home() / 'Library/MobileDevice/Provisioning Profiles']
70 for path in sorted(path for folder in folders if folder.is_dir()
71 for path in folder.glob('*.provisionprofile')):
72 if profile_entitlements(path):
73 return path
74 return None
24profile = ['--release'] if args.release else []75profile = ['--release'] if args.release else []
25if args.snow_leopard:76if args.snow_leopard:
26 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,77 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,
...@@ -54,9 +105,16 @@ if args.snow_leopard:...@@ -54,9 +105,16 @@ if args.snow_leopard:
54else:105else:
55 icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'106 icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'
56 shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns')107 shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns')
108# tools/release.py names the build, as 2026-09-29-r10, which About shows.
109build = os.environ.get('SNOWBOUND_BUILD')
110versions = {}
111if build:
112 date, revision = build.split('-r')
113 versions = {'CFBundleShortVersionString': f'{date} revision {revision}',
114 'CFBundleVersion': f'{date.replace("-", "")}.{revision}'}
57(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({115(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({
58 'CFBundleExecutable': binary.name,116 'CFBundleExecutable': binary.name,
59 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app',117 'CFBundleIdentifier': args.bundle_id or BUNDLE_ID,
60 'CFBundleName': bundle.stem,118 'CFBundleName': bundle.stem,
61 'CFBundleDisplayName': bundle.stem,119 'CFBundleDisplayName': bundle.stem,
62 'CFBundlePackageType': 'APPL',120 'CFBundlePackageType': 'APPL',
...@@ -64,10 +122,37 @@ else:...@@ -64,10 +122,37 @@ else:
64 'NSHighResolutionCapable': True,122 'NSHighResolutionCapable': True,
65 'NSPrincipalClass': 'NSApplication',123 'NSPrincipalClass': 'NSApplication',
66 'CFBundleIconFile': 'Snowbound',124 'CFBundleIconFile': 'Snowbound',
67 'NSMicrophoneUsageDescription': 'Snowbound records audio into your notes when you choose Record Audio.',125 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.',
68} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))126 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',
127 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',
128 'NSUbiquitousContainers': {CONTAINER: {
129 'NSUbiquitousContainerIsDocumentScopePublic': True,
130 'NSUbiquitousContainerName': 'Snowbound',
131 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
132 }},
133} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
69# 10.6 runs the bundle unsigned.134# 10.6 runs the bundle unsigned.
70if not args.snow_leopard:135if not args.snow_leopard:
71 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)136 identity = args.sign_identity or developer_id()
137 profile = args.profile or developer_id_profile()
138 if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID:
139 shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile')
140 with tempfile.TemporaryDirectory() as scratch:
141 entitlements = Path(scratch) / 'entitlements.plist'
142 entitlements.write_bytes(plistlib.dumps({
143 'com.apple.application-identifier': f'{TEAM}.{BUNDLE_ID}',
144 'com.apple.developer.team-identifier': TEAM,
145 'com.apple.developer.icloud-services': ['CloudDocuments'],
146 'com.apple.developer.icloud-container-identifiers': [CONTAINER],
147 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER],
148 # Hardened runtime's Record Audio and Record Video.
149 'com.apple.security.device.audio-input': True,
150 'com.apple.security.device.camera': True,
151 }))
152 subprocess.run(['codesign', '--force', '--options', 'runtime', '--entitlements', entitlements,
153 '--sign', identity, str(bundle)], check=True)
154 print(f'Signed with the Developer ID of team {TEAM}, with the iCloud container {CONTAINER}.')
155 else:
156 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
72 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)157 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
73print(bundle)158print(bundle)
tools/native/custom-art-edit.ps1 created+45
...@@ -0,0 +1,45 @@
1param([Parameter(Mandatory=$true)][string]$Root)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$notebook = Join-Path $Root 'notebook'
5$app = New-Object -ComObject OneNote.Application
6try {
7 $hierarchy = ''
8 $app.GetHierarchy('', 4, [ref]$hierarchy, 1)
9 [xml]$tree = $hierarchy
10 $page = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]//*[local-name()="Page"]')
11 if (-not $page) { throw 'The notebook has no page open.' }
12 $content = ''
13 $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 0, 1)
14 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
15 [xml]$xml = $content
16 # OneNote edits the tagged paragraphs: new text on one, the check box of another cleared.
17 foreach ($oe in $xml.SelectNodes('//*[local-name()="OE"]')) {
18 $text = $oe.SelectSingleNode('*[local-name()="T"]')
19 if (-not $text) { continue }
20 if ($text.InnerText -eq 'Launch day') { $text.InnerXml = '<![CDATA[Launch day, moved to Friday]]>' }
21 if ($text.InnerText -eq 'Checked off') {
22 foreach ($tag in $oe.SelectNodes('*[local-name()="Tag"]')) { $tag.SetAttribute('completed', 'false') }
23 }
24 }
25 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $xml.OuterXml, [Text.Encoding]::UTF8)
26 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false)
27 $id = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]').GetAttribute('ID')
28 $app.SyncHierarchy($id)
29 Start-Sleep -Seconds 5
30} finally {
31 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
32 [GC]::Collect()
33 [GC]::WaitForPendingFinalizers()
34}
35# What OneNote left of Snowbound's folder: its attribute, and each file with its hash.
36$folder = Get-Item -LiteralPath (Join-Path $notebook '.snowbound') -Force
37$files = @(Get-ChildItem -LiteralPath $folder.FullName -Recurse -Force | ForEach-Object {
38 $hash = $null
39 if (-not $_.PSIsContainer) { $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLower() }
40 @{ path = $_.FullName.Substring($folder.FullName.Length + 1).Replace('\', '/');
41 attributes = $_.Attributes.ToString(); sha256 = $hash }
42})
43@{ attributes = $folder.Attributes.ToString(); files = $files;
44 notebook = @(Get-ChildItem -LiteralPath $notebook -Force | ForEach-Object { $_.Name }) } |
45 ConvertTo-Json -Depth 4 | Set-Content (Join-Path $Root 'sidecar.json') -Encoding UTF8
tools/native/custom-art.ps1 created+7
...@@ -0,0 +1,7 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5# Snowbound makes its folder hidden, which the transfer's archive does not keep.
6$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force
7$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden
tools/native/ink-pressure.ps1 created+108
...@@ -0,0 +1,108 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2# Pressure ink as OneNote 2010 keeps it: WPF writes strokes with NormalPressure (and one with
3# tilt) as ISF, and OneNote takes each through UpdatePageContent into a section of its own.
4Set-StrictMode -Version Latest
5$ErrorActionPreference = 'Stop'
6& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
7Add-Type -AssemblyName PresentationCore, WindowsBase
8$P = [System.Windows.Input.StylusPointProperties]
9$U = [System.Windows.Input.StylusPointPropertyUnit]
10
11function Info($property, $min, $max, $unit, $resolution) {
12 if ($null -eq $min) { return New-Object System.Windows.Input.StylusPointPropertyInfo($property) }
13 New-Object System.Windows.Input.StylusPointPropertyInfo($property, $min, $max, $unit, $resolution)
14}
15
16# A stroke through `points` of [x, y, pressure 0..1, extra values...] in WPF units (1/96 inch).
17function Stroke($points, $width, $height, [switch]$Ignore, [switch]$Rectangle, $infos) {
18 if (-not $infos) { $infos = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure)) }
19 $description = New-Object System.Windows.Input.StylusPointDescription(,[System.Windows.Input.StylusPointPropertyInfo[]]$infos)
20 $pressure = $infos[2]
21 $collection = New-Object System.Windows.Input.StylusPointCollection(,$description)
22 foreach ($p in $points) {
23 $extra = [int[]]@($p | Select-Object -Skip 3)
24 $collection.Add((New-Object System.Windows.Input.StylusPoint($p[0], $p[1], [single]0.5, $description, $extra)))
25 $point = $collection[$collection.Count - 1]
26 $point.SetPropertyValue([System.Windows.Input.StylusPointProperties]::NormalPressure,
27 [int][Math]::Round($pressure.Minimum + $p[2] * ($pressure.Maximum - $pressure.Minimum)))
28 $collection[$collection.Count - 1] = $point
29 }
30 $stroke = New-Object System.Windows.Ink.Stroke -ArgumentList (,$collection)
31 $stroke.DrawingAttributes.Width = $width
32 $stroke.DrawingAttributes.Height = $height
33 $stroke.DrawingAttributes.IgnorePressure = [bool]$Ignore
34 $stroke.DrawingAttributes.FitToCurve = $false
35 if ($Rectangle) { $stroke.DrawingAttributes.StylusTip = [System.Windows.Ink.StylusTip]::Rectangle }
36 $stroke
37}
38
39function Isf($strokes) {
40 $collection = New-Object System.Windows.Ink.StrokeCollection
41 foreach ($stroke in $strokes) { $collection.Add([System.Windows.Ink.Stroke]$stroke) }
42 $stream = New-Object IO.MemoryStream
43 $collection.Save($stream, $true)
44 [Convert]::ToBase64String($stream.ToArray())
45}
46
47function Line($x, $y, $pressure, $count, $step) {
48 $points = @()
49 for ($i = 0; $i -lt $count; $i++) { $points += ,@(($x + $i * $step), $y, $pressure) }
50 ,$points
51}
52
53# "Levels": one drawing of nine 24-point strokes at pressures 0 to 1 in eighths, and one of
54# the same levels on a pen reporting 0..255.
55$levels = @(); $coarse = @()
56$narrow = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure 0 255 $U::None 1))
57for ($i = 0; $i -le 8; $i++) {
58 $levels += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32
59 $coarse += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32 -infos $narrow
60}
61# "Strokes": a rising ramp, a wave, a pen ignoring the pressure it recorded, a stroke with
62# tilt, and a rectangular tip, each a drawing of its own.
63$ramp = @(); $wave = @(); $flat = @(); $tilt = @(); $block = @()
64for ($i = 0; $i -le 40; $i++) {
65 $ramp += ,@(($i * 8), 0, ($i / 40))
66 $wave += ,@(($i * 8), (20 * [Math]::Sin($i / 4)), (0.5 + 0.5 * [Math]::Sin($i / 5)))
67 $flat += ,@(($i * 8), 0, ($i / 40))
68 $tilt += ,@(($i * 8), 0, (0.2 + $i / 60), (-4500 + $i * 200), 3000)
69}
70for ($i = 0; $i -le 10; $i++) { $block += ,@(($i * 20), 0, ($i / 10)) }
71$tilted = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure),
72 (Info $P::XTiltOrientation -9000 9000 $U::Degrees 100), (Info $P::YTiltOrientation -9000 9000 $U::Degrees 100))
73$pages = [ordered]@{
74 'Levels' = @(@(36, 90, (Isf $levels)), @(252, 90, (Isf $coarse)))
75 'Strokes' = @(
76 @(36, 90, (Isf @(Stroke $ramp 8 8))),
77 @(36, 150, (Isf @(Stroke $wave 4 4))),
78 @(36, 220, (Isf @(Stroke $flat 8 8 -Ignore))),
79 @(36, 270, (Isf @(Stroke $tilt 8 8 -infos $tilted))),
80 @(36, 320, (Isf @(Stroke $block 4 24 -Rectangle))))
81}
82
83$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
84$app = New-Object -ComObject OneNote.Application
85$notebookId = ''
86try {
87 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
88 $sectionId = ''
89 $app.OpenHierarchy('Pressure.one', $notebookId, [ref]$sectionId, 3)
90 foreach ($name in $pages.Keys) {
91 $pageId = ''
92 $app.CreateNewPage($sectionId, [ref]$pageId, 0)
93 $drawings = ($pages[$name] | ForEach-Object {
94 "<one:InkDrawing><one:Position x=`"$($_[0])`" y=`"$($_[1])`"/><one:Data>$($_[2])</one:Data></one:InkDrawing>"
95 }) -join ''
96 $xml = "<?xml version=`"1.0`"?><one:Page xmlns:one=`"$namespace`" ID=`"$pageId`"><one:Title><one:OE><one:T><![CDATA[$name]]></one:T></one:OE></one:Title>$drawings</one:Page>"
97 [IO.File]::WriteAllText((Join-Path $Root "author-$name.xml"), $xml, [Text.Encoding]::UTF8)
98 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
99 }
100 $app.SyncHierarchy($notebookId)
101 $app.CloseNotebook($notebookId, $false)
102 $notebookId = ''
103} finally {
104 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
105 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
106 [GC]::Collect()
107 [GC]::WaitForPendingFinalizers()
108}
tools/native_cross_container.py created+66
...@@ -0,0 +1,66 @@
1"""Delete and type over selections crossing a table's edge in OneNote 2010 and capture what
2it stores. The oracle for the canvas editor's cross-container deletion.
3
4Usage: native_cross_container.py NOTEBOOK_DIR OUTPUT_DIR
5"""
6import base64
7import json
8import sys
9from pathlib import Path
10import xml.etree.ElementTree as ET
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13import native_runner as runner
14from native_xml import ns
15
16windows = runner.windows
17
18# Each page's keys once the caret stands in the line clicked: from "Before" into "Alpha one",
19# from "Alpha two" out to "After", from "Before" over the whole first row into "Beta one",
20# and from "Before" over the whole table into "After".
21KEYS = {
22 'Delete into a table': ((160, 236), '{Home}{Right 6}', 11, 'Send("{Delete}")'),
23 'Delete out of a table': ((250, 260), '{Home}{Right 5}', 28, 'Send("{Delete}")\nSleep(500)\nSendText("X")'),
24 'Type over a row': ((160, 236), '{Home}{Right 6}', 30, 'SendText("Y")'),
25 'Delete a whole table': ((160, 236), '{Home}{Right 6}', 49, 'Send("{Delete}")'),
26}
27
28
29def interaction(name, output):
30 pages = {}
31 for path in sorted((output / 'read').glob('page-*.xml')):
32 page = ET.parse(path).getroot()
33 title = page.find('one:Title/one:OE/one:T', ns)
34 if title is not None and title.text in KEYS:
35 pages[title.text] = page.attrib['ID']
36 for title, ((x, y), place, extend, action) in KEYS.items():
37 script = '''ComObject("OneNote.Application").NavigateTo("%s", "", false)
38WinWait("ahk_exe ONENOTE.EXE", , 30)
39WinActivate("ahk_exe ONENOTE.EXE")
40WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
41WinMaximize("ahk_exe ONENOTE.EXE")
42Sleep(3000)
43CoordMode("Mouse", "Screen")
44Click(%d, %d)
45Sleep(500)
46Send("%s")
47Sleep(300)
48Send("+{Right %d}")
49Sleep(500)
50%s
51Sleep(2000)
52''' % (pages[title], x, y, place, extend, action)
53 stem = title.lower().replace(' ', '-')
54 (output / (stem + '.ahk')).write_text(script)
55 result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500)
56 screenshot = result.pop('png_b64', None)
57 if screenshot:
58 (output / (stem + '.png')).write_bytes(base64.b64decode(screenshot))
59 (output / (stem + '.json')).write_text(json.dumps(result, indent=2))
60 if result.get('error') or result.get('exit') != 0:
61 raise RuntimeError(str(result))
62
63
64if __name__ == '__main__':
65 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
66 runner.capture(notebook, output, expected_pages=-1, collect_notebook=True, interaction=interaction)
tools/native_custom_art.py created+38
...@@ -0,0 +1,38 @@
1"""Cold-open a notebook whose tags carry Snowbound's art in OneNote 2010, have OneNote edit
2the tagged page, and capture what it shows, stores and leaves of the `.snowbound` folder.
3
4Usage: native_custom_art.py NOTEBOOK_DIR OUTPUT_DIR
5"""
6import base64
7import json
8import sys
9from pathlib import Path
10import xml.etree.ElementTree as ET
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13import native_runner as runner
14
15windows = runner.windows
16EDIT = Path(__file__).resolve().parent / 'native/custom-art-edit.ps1'
17
18
19def interaction(name, output):
20 (output / 'scripts' / EDIT.name).write_bytes(EDIT.read_bytes())
21 result = windows.do_put(EDIT, 'C:\\one-tests\\custom-art-edit.ps1', name)
22 if result.get('error'):
23 raise RuntimeError(result['error'])
24 runner.command(name, 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\custom-art-edit.ps1 -Root C:\\one-tests\\runs\\capture', output, 300000)
25 page, = sorted((output / 'read').glob('page-*.xml'))
26 result = windows.do_exec(runner.navigation_script(ET.parse(page).getroot().attrib['ID']), target=name, shot_delay_ms=1500)
27 screenshot = result.pop('png_b64', None)
28 if screenshot:
29 (output / 'edited.png').write_bytes(base64.b64decode(screenshot))
30 (output / 'edited.json').write_text(json.dumps(result, indent=2))
31 if result.get('error') or result.get('exit') != 0:
32 raise RuntimeError(str(result))
33
34
35if __name__ == '__main__':
36 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
37 runner.capture(notebook, output, expected_pages=1, author=Path(__file__).resolve().parent / 'native/custom-art.ps1',
38 screenshots=True, collect_notebook=True, interaction=interaction)
tools/native_object_outline.py created+57
...@@ -0,0 +1,57 @@
1"""Type beside a file and a picture alone in their outlines in OneNote 2010 and capture what it
2stores. The oracle for the canvas editor's provisional paragraph after an outline's objects.
3
4Usage: native_object_outline.py NOTEBOOK_DIR OUTPUT_DIR
5"""
6import base64
7import json
8import sys
9from pathlib import Path
10import xml.etree.ElementTree as ET
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13import native_runner as runner
14from native_xml import ns
15
16windows = runner.windows
17
18# Where a click lands beside each page's object, inside its outline (at 72, 108 pt).
19CLICKS = {'File alone': (236, 301), 'Picture alone': (238, 282)}
20
21
22def interaction(name, output):
23 pages = {}
24 for path in sorted((output / 'read').glob('page-*.xml')):
25 page = ET.parse(path).getroot()
26 title = page.find('one:Title/one:OE/one:T', ns)
27 if title is not None and title.text in CLICKS:
28 pages[title.text] = page.attrib['ID']
29 for title, (x, y) in CLICKS.items():
30 script = '''ComObject("OneNote.Application").NavigateTo("%s", "", false)
31WinWait("ahk_exe ONENOTE.EXE", , 30)
32WinActivate("ahk_exe ONENOTE.EXE")
33WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
34WinMaximize("ahk_exe ONENOTE.EXE")
35Sleep(3000)
36CoordMode("Mouse", "Screen")
37MouseMove(190, 260)
38Sleep(800)
39Click(%d, %d)
40Sleep(500)
41SendText("abc")
42Sleep(2000)
43''' % (pages[title], x, y)
44 stem = title.lower().replace(' ', '-')
45 (output / (stem + '.ahk')).write_text(script)
46 result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500)
47 screenshot = result.pop('png_b64', None)
48 if screenshot:
49 (output / (stem + '.png')).write_bytes(base64.b64decode(screenshot))
50 (output / (stem + '.json')).write_text(json.dumps(result, indent=2))
51 if result.get('error') or result.get('exit') != 0:
52 raise RuntimeError(str(result))
53
54
55if __name__ == '__main__':
56 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
57 runner.capture(notebook, output, expected_pages=-1, collect_notebook=True, interaction=interaction)
tools/release.py created+226
...@@ -0,0 +1,226 @@
1#!/usr/bin/env python3
2"""Builds, signs and publishes Snowbound for each desktop platform; see tools/RELEASE.md."""
3import argparse
4from datetime import datetime
5import hashlib
6import json
7import os
8from pathlib import Path
9import plistlib
10import shutil
11import subprocess
12import sys
13import tempfile
14from zoneinfo import ZoneInfo
15
16ROOT = Path(__file__).resolve().parents[1]
17PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
18URL = 'https://file.paperclover.net/shr/snowbound/'
19KEY = Path.home() / '.config/snowbound/release-key'
20ZONE = ZoneInfo('America/Los_Angeles')
21PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64']
22CHECKS = [
23 ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'],
24 ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'],
25 ['cargo', 'test', '--workspace', '--all-features'],
26]
27# Clover's Developer ID Application certificate, by its SHA-1 hash: its name is the account
28# holder's legal name, which nothing here prints or stores.
29IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
30# The notarytool keychain profile that notarizes it, once `xcrun notarytool store-credentials` makes it.
31NOTARY = 'snowbound'
32# What hardened runtime needs for Record Audio and Record Video.
33ENTITLEMENTS = {
34 'com.apple.security.device.audio-input': True,
35 'com.apple.security.device.camera': True,
36}
37
38
39def derive(release, commits):
40 """The version of the commit made at `release`, given when each commit leading to it was
41 made, itself included: its day in Los Angeles, and how many of those were made that day."""
42 day = release.astimezone(ZONE).date()
43 return day.isoformat(), sum(1 for made in commits if made.astimezone(ZONE).date() == day)
44
45
46def name(version):
47 return f'{version[0]}-r{version[1]}'
48
49
50def folder(version):
51 """The build's top-level folder."""
52 return f'{version[0]}.r{version[1]}'
53
54
55def parse(text):
56 date, revision = text.split('-r')
57 return date, int(revision)
58
59
60def newest(latest, archives, version):
61 """`latest.json`'s map with each of `archives`' platforms moved to `version` where that is
62 newer than what it names."""
63 return {**latest, **{platform: name(version) for platform in archives
64 if platform not in latest or parse(latest[platform]) < version}}
65
66
67def jj(*args):
68 return subprocess.check_output(['jj', *args], cwd=ROOT, text=True)
69
70
71def clean(dry_run, moment):
72 """Refuses to go on, or in a dry run warns, where the working copy isn't `main`."""
73 changed = jj('diff', '--from', 'main', '--to', '@', '--summary').strip()
74 if changed and not dry_run:
75 sys.exit(f'The working copy differs from main {moment}:\n{changed}')
76 if changed:
77 print(f'Dry run: the working copy differs from main {moment}.', file=sys.stderr)
78
79
80def run(command, **kwargs):
81 print('+', ' '.join(map(str, command)), flush=True)
82 subprocess.run(command, cwd=ROOT, check=True, **kwargs)
83
84
85def commit_times(revset):
86 template = 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\n"'
87 return [datetime.fromisoformat(line) for line in jj('log', '--no-graph', '-r', revset, '-T', template).split()]
88
89
90def sign(files):
91 output = subprocess.check_output(
92 ['cargo', 'run', '--quiet', '--release', '-p', 'snowbound', '--example', 'release_sign', '--', KEY, *files],
93 cwd=ROOT, text=True)
94 return output.split()
95
96
97def zip_bundle(bundle, archive):
98 run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive])
99
100
101def notarizes():
102 """Whether the NOTARY profile exists and signs in."""
103 return subprocess.run(['xcrun', 'notarytool', 'history', '--keychain-profile', NOTARY],
104 capture_output=True).returncode == 0
105
106
107def build_mac(platform, folder, developer_id, notarize):
108 """The zipped app; 10.6's stays unsigned, as it predates Developer ID."""
109 bundle = folder / 'Snowbound.app'
110 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle]
111 + (['--snow-leopard'] if platform == 'macos-10.6' else []))
112 archive = folder / 'archive.zip'
113 if developer_id and platform != 'macos-10.6':
114 entitlements = folder / 'entitlements.plist'
115 entitlements.write_bytes(plistlib.dumps(ENTITLEMENTS))
116 run(['codesign', '--force', '--options', 'runtime', '--timestamp', '--entitlements', entitlements,
117 '--sign', IDENTITY, bundle])
118 if notarize:
119 zip_bundle(bundle, archive)
120 run(['xcrun', 'notarytool', 'submit', archive, '--keychain-profile', NOTARY, '--wait'])
121 run(['xcrun', 'stapler', 'staple', bundle])
122 archive.unlink()
123 zip_bundle(bundle, archive)
124 return archive
125
126
127def build_linux(architectures):
128 """The executables, each all of Snowbound for its architecture."""
129 run(['sh', ROOT / 'crates/snowbound/linux/package.sh', *architectures])
130 return {f'linux-{arch}': ROOT / f'target/{arch}-unknown-linux-gnu/release/snowbound' for arch in architectures}
131
132
133def main():
134 parser = argparse.ArgumentParser(description=__doc__)
135 parser.add_argument('--dry-run', action='store_true',
136 help='Publish into a new temporary folder instead, even from a changed working copy')
137 parser.add_argument('--platforms', nargs='+', choices=PLATFORMS, default=PLATFORMS)
138 parser.add_argument('--ad-hoc', action='store_true',
139 help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized')
140 args = parser.parse_args()
141 developer_id = not args.ad_hoc and any(platform == 'macos-aarch64' for platform in args.platforms)
142 identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'],
143 capture_output=True, text=True).stdout
144 if developer_id and IDENTITY not in identities:
145 sys.exit(f'The keychain has no signing identity {IDENTITY}; release with --ad-hoc, or add it.')
146 notarize = developer_id and notarizes()
147 if developer_id and not notarize:
148 print(f'Not notarizing: no notarytool profile "{NOTARY}" that signs in (see tools/RELEASE.md).',
149 file=sys.stderr)
150
151 commit = jj('log', '--no-graph', '-r', 'main', '-T', 'commit_id').strip()
152 clean(args.dry_run, 'to release it')
153 version = derive(commit_times('main')[0], commit_times('::main'))
154 print(f'Snowbound build {version[0]} revision {version[1]}, commit {commit}', flush=True)
155
156 published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED
157 if not published.is_dir():
158 sys.exit(f'{published} is not mounted.')
159 target = published / folder(version)
160 if target.exists():
161 build = json.loads((target / 'build.json').read_text())
162 if build['commit'] != commit:
163 sys.exit(f'{target} holds commit {build["commit"]}, not {commit}.')
164 print(f'{target} is already published.')
165 else:
166 for check in CHECKS:
167 run(check)
168 clean(args.dry_run, 'after the checks')
169 stage = ROOT / 'target/release-stage' / name(version)
170 shutil.rmtree(stage, ignore_errors=True)
171 stage.mkdir(parents=True)
172 # The app reads its version from this as it compiles.
173 os.environ['SNOWBOUND_BUILD'] = name(version)
174 built = {}
175 for platform in args.platforms:
176 if platform.startswith('macos'):
177 folder = stage / platform
178 folder.mkdir()
179 built[platform] = build_mac(platform, folder, developer_id, notarize)
180 linux = [platform.removeprefix('linux-') for platform in args.platforms if platform.startswith('linux')]
181 if linux:
182 built |= build_linux(linux)
183 clean(args.dry_run, 'after the build')
184 files = {}
185 for platform, source in built.items():
186 prefix = 'Snowbound' if platform.startswith('macos') else 'snowbound'
187 files[platform] = stage / f'{prefix}-{name(version)}-{platform}{source.suffix}'
188 shutil.copy2(source, files[platform])
189 signatures = sign(files.values())
190 build = {
191 'version': name(version),
192 'commit': commit,
193 'published': datetime.now(ZONE).isoformat(timespec='seconds'),
194 'archives': {platform: {
195 'file': file.name,
196 'size': file.stat().st_size,
197 'sha256': hashlib.sha256(file.read_bytes()).hexdigest(),
198 'signature': signature,
199 } for (platform, file), signature in zip(files.items(), signatures)},
200 }
201 (stage / 'build.json').write_text(json.dumps(build, indent=2) + '\n')
202 (stage / 'build.json.sig').write_text(sign([stage / 'build.json'])[0] + '\n')
203 partial = target.with_name(f'.{target.name}.partial')
204 shutil.rmtree(partial, ignore_errors=True)
205 partial.mkdir()
206 for file in [*files.values(), stage / 'build.json', stage / 'build.json.sig']:
207 shutil.copyfile(file, partial / file.name)
208 partial.rename(target)
209 shutil.rmtree(stage)
210 print(f'Published {target}')
211
212 latest_file = published / 'latest.json'
213 latest = json.loads(latest_file.read_text()) if latest_file.exists() else {}
214 build = json.loads((target / 'build.json').read_text())
215 moved = newest(latest, build['archives'], version)
216 if moved != latest:
217 partial = latest_file.with_name('.latest.json.partial')
218 partial.write_text(json.dumps(moved, indent=2, sort_keys=True) + '\n')
219 os.replace(partial, latest_file)
220 print(f'{latest_file}: {json.dumps(moved, sort_keys=True)}')
221 if not args.dry_run:
222 print(f'{URL}{folder(version)}/')
223
224
225if __name__ == '__main__':
226 main()
tools/test_cross_container.py created+49
...@@ -0,0 +1,49 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/cross-container'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16def blocks(read):
17 """Each page's body by title: paragraph texts, and tables as rows of cell paragraph texts."""
18 text = lambda node: re.sub(r'<[^>]*>', '', node.text or '')
19 result = {}
20 for path in sorted(read.glob('page-*.xml')):
21 page = ET.parse(path).getroot()
22 title = page.find('one:Title/one:OE/one:T', ns)
23 body = page.find('one:Outline/one:OEChildren', ns)
24 if title is None or body is None:
25 continue
26 result[title.text] = [
27 [[[text(t) for t in cell.findall('one:OEChildren/one:OE/one:T', ns)]
28 for cell in row.findall('one:Cell', ns)]
29 for row in oe.find('one:Table', ns).findall('one:Row', ns)]
30 if oe.find('one:Table', ns) is not None else text(oe.find('one:T', ns))
31 for oe in body.findall('one:OE', ns)]
32 return result
33
34
35class CrossContainerTest(unittest.TestCase):
36 def test_deletions_across_a_tables_edge_reopen_as_onenote_made_them(self):
37 with TemporaryDirectory() as temporary:
38 read = Path(temporary) / 'read'
39 shutil.copytree(FIXTURE / 'cold/read', read)
40 compare(FIXTURE / 'candidate', read)
41 onenote = blocks(FIXTURE / 'keys/read')
42 cold = blocks(FIXTURE / 'cold/read')
43 self.assertEqual(len(cold), 4)
44 for title, body in cold.items():
45 self.assertEqual(body, onenote[title], title)
46
47
48if __name__ == '__main__':
49 unittest.main()
tools/test_custom_art.py created+61
...@@ -0,0 +1,61 @@
1from pathlib import Path
2import hashlib
3import json
4import runpy
5import shutil
6from tempfile import TemporaryDirectory
7import unittest
8import xml.etree.ElementTree as ET
9
10from native_xml import ns
11
12ROOT = Path(__file__).resolve().parent.parent
13FIXTURE = ROOT / 'corpus/custom-art'
14compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
15
16
17def tagged(read):
18 """The page's tagged paragraphs: text, then each tag as (name, symbol, completed)."""
19 page = ET.parse(read / 'page-000.xml').getroot()
20 definitions = {d.get('index'): (d.get('name'), int(d.get('symbol'))) for d in page.findall('one:TagDef', ns)}
21 return [((oe.find('one:T', ns).text or ''),
22 [definitions[tag.get('index')] + (tag.get('completed'),) for tag in oe.findall('one:Tag', ns)])
23 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:Tag', ns) is not None]
24
25
26class CustomArtTest(unittest.TestCase):
27 def test_tags_with_art_cold_open_with_their_fallback_symbols(self):
28 with TemporaryDirectory() as temporary:
29 read = Path(temporary) / 'read'
30 shutil.copytree(FIXTURE / 'cold/before-read', read)
31 compare(FIXTURE / 'candidate', read)
32 self.assertEqual(tagged(FIXTURE / 'cold/before-read'), [
33 ('Launch day', [('Launch', 13, 'true')]),
34 ('Ship the release', [('Ship it', 127, 'true')]),
35 ('Checked off', [('Done', 3, 'true')]),
36 ('Plain OneNote tag', [('Important', 13, 'true')]),
37 ])
38
39 def test_onenote_editing_the_page_keeps_the_mapping_and_the_folder(self):
40 edited = tagged(FIXTURE / 'cold/read')
41 self.assertEqual([text for text, _ in edited][:1], ['Launch day, moved to Friday'])
42 self.assertEqual(edited[2][1], [('Done', 3, 'false')])
43 # Every tag the mapping names is still on the page under its name and symbol.
44 sidecar = FIXTURE / 'candidate/.snowbound'
45 mapped = {(m['name'], m['shape']): m['art'] for m in json.loads((sidecar / 'tags.json').read_text())}
46 stored = {tag[:2] for _, tags in edited for tag in tags}
47 self.assertEqual(set(mapped) - stored, set())
48 for art in mapped.values():
49 self.assertEqual(hashlib.sha256((sidecar / 'tags' / art).read_bytes()).hexdigest(), art.split('.')[0])
50 # OneNote kept the folder hidden, left no table of contents in it and changed none of it.
51 left = json.loads((FIXTURE / 'cold/sidecar.json').read_text(encoding='utf-8-sig'))
52 self.assertIn('Hidden', left['attributes'])
53 files = {f['path']: f['sha256'] for f in left['files'] if f['sha256']}
54 expected = {p.relative_to(sidecar).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
55 for p in sidecar.rglob('*') if p.is_file()}
56 self.assertEqual(files, expected)
57 self.assertEqual(sorted(left['notebook']), ['.snowbound', 'Custom art.one', 'Open Notebook.onetoc2'])
58
59
60if __name__ == '__main__':
61 unittest.main()
tools/test_equation_join.py created+33
...@@ -0,0 +1,33 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7
8ROOT = Path(__file__).resolve().parent.parent
9FIXTURE = ROOT / 'corpus/equation-join'
10compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
11
12
13def mathml(xml):
14 """Each equation's MathML in a COM read, trailing spaces dropped."""
15 return [re.sub(r'(<mml:mo>&nbsp;</mml:mo>)+</mml:math>', '</mml:math>', m)
16 for m in re.findall(r'<mml:math[^!]*?</mml:math>', xml)]
17
18
19class EquationJoinTest(unittest.TestCase):
20 def test_joined_equations_reopen_as_onenote_joined_them(self):
21 with TemporaryDirectory() as temporary:
22 read = Path(temporary) / 'read'
23 shutil.copytree(FIXTURE / 'cold/read', read)
24 compare(FIXTURE / 'candidate', read)
25 onenote = mathml((FIXTURE / 'lab/equation-seam.xml').read_text())
26 cold = mathml(''.join(path.read_text(encoding='utf-8-sig') for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))))
27 self.assertEqual(len(onenote), 1)
28 self.assertEqual([m.replace(' display="block"', '') for m in cold],
29 [m.replace(' display="block"', '') for m in onenote])
30
31
32if __name__ == '__main__':
33 unittest.main()
tools/test_ink_pressure.py created+112
...@@ -0,0 +1,112 @@
1"""Pressure ink as OneNote 2010 keeps and draws it (`corpus/ink-pressure`), read from its PDF
2exports: every point of a stroke is a round stamp whose diameter is the pen's width times
30.25 + 1.5 * pressure."""
4from collections import defaultdict
5from pathlib import Path
6import re
7import runpy
8import shutil
9from tempfile import TemporaryDirectory
10import unittest
11import xml.etree.ElementTree as ET
12import zlib
13
14from native_xml import ns
15
16ROOT = Path(__file__).resolve().parent.parent
17FIXTURE = ROOT / 'corpus/ink-pressure'
18compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
19NUMBER = r'(-?[\d.]+)'
20STAMP = re.compile(rf'{NUMBER} {NUMBER} m\s+{NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} c\s+'
21 rf'{NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} c')
22
23
24def page(read, name):
25 """A page's XML and its PDF export."""
26 for path in sorted(read.glob('page-*.xml')):
27 root = ET.parse(path).getroot()
28 if root.get('name') == name:
29 return root, path.with_suffix('.pdf')
30 raise KeyError(name)
31
32
33def stamps(pdf):
34 """Each round stamp OneNote exports, as (bottom, diameter) in PDF points."""
35 data = pdf.read_bytes()
36 streams = []
37 for match in re.finditer(rb'stream\r?\n(.*?)\r?\nendstream', data, re.S):
38 try:
39 streams.append(zlib.decompress(match.group(1)).decode('latin1'))
40 except zlib.error:
41 pass
42 return [(float(m.group(8)), float(m.group(1)) - float(m.group(13)))
43 for m in STAMP.finditer('\n'.join(streams))]
44
45
46def rows(pdf):
47 """Stamp diameters by the height of their bottom edge, rounded to a tenth of a point."""
48 found = defaultdict(list)
49 for bottom, diameter in stamps(pdf):
50 found[round(bottom, 1)].append(diameter)
51 return found
52
53
54def thickness(pressure):
55 return 0.25 + 1.5 * pressure
56
57
58class InkPressureTest(unittest.TestCase):
59 def test_onenote_draws_a_quarter_of_the_pen_at_no_pressure_and_1_75_times_at_full(self):
60 _, pdf = page(FIXTURE / 'native/read', 'Levels')
61 # Two drawings of nine 24-point strokes at pressures 0 to 1 in eighths: one pen reports
62 # 1024 levels, the other 256, and both draw alike.
63 found = sorted(rows(pdf).items(), reverse=True)
64 diameters = [d for _, row in found for d in row]
65 self.assertEqual(len(diameters), 2 * 9 * 5)
66 eighths = [((d / 24 - 0.25) / 1.5) * 8 for d in diameters]
67 for eighth in eighths:
68 self.assertAlmostEqual(eighth, round(eighth), delta=0.03)
69 self.assertEqual(sorted(round(e) for e in eighths), sorted(list(range(9)) * 10))
70
71 def test_a_pen_ignoring_pressure_draws_one_width(self):
72 root, _ = page(FIXTURE / 'native/read', 'Strokes')
73 self.assertEqual(len(root.findall('one:InkDrawing', ns)), 5)
74 self.assertTrue((FIXTURE / 'native/read/page-000.png').exists())
75
76 def test_snowbound_pressure_strokes_open_in_onenote_at_their_widths(self):
77 with TemporaryDirectory() as temporary:
78 read = Path(temporary) / 'read'
79 shutil.copytree(FIXTURE / 'cold/read', read)
80 compare(FIXTURE / 'candidate', read)
81 _, pdf = page(FIXTURE / 'cold/read', 'Levels')
82 found = rows(pdf)
83 # Snowbound's strokes of a 500 HIMETRIC pen at none, half and full pressure.
84 pen = 500 * 72 / 2540
85 snowbound = [row for bottom, row in found.items() if len(row) == 3]
86 self.assertEqual(len(snowbound), 3)
87 for pressure, row in zip([1.0, 0.5, 0.0], sorted(snowbound, key=lambda row: row[0], reverse=True)):
88 for diameter in row:
89 self.assertAlmostEqual(diameter, pen * thickness(pressure), delta=0.06)
90 # Its ramp widens point by point from a quarter of the pen to 1.75 times it.
91 ramp = sorted(d for _, row in found.items() if len(row) == 1 for d in row)
92 self.assertEqual(len(ramp), 41)
93 self.assertAlmostEqual(ramp[0], pen * 0.25, delta=0.06)
94 self.assertAlmostEqual(ramp[-1], pen * 1.75, delta=0.06)
95
96 def test_onenote_pressure_ink_keeps_its_widths_after_snowbound_edits(self):
97 before = rows(page(FIXTURE / 'native/read', 'Levels')[1])
98 after = rows(page(FIXTURE / 'cold/read', 'Levels')[1])
99 # The erased stroke at no pressure leaves the 1024-level drawing; the rest draw as before.
100 onenote = lambda found: sorted(round(d, 1) for row in found.values() if len(row) in (5, 10) for d in row)
101 erased = onenote(before)
102 for _ in range(5):
103 erased.remove(6.0)
104 self.assertEqual(onenote(after), erased)
105 drawings = page(FIXTURE / 'cold/read', 'Strokes')[0].findall('one:InkDrawing', ns)
106 self.assertEqual(len(drawings), 4)
107 position = drawings[0].find('one:Position', ns)
108 self.assertEqual((float(position.get('x')), float(position.get('y'))), (72.0, 108.0))
109
110
111if __name__ == '__main__':
112 unittest.main()
tools/test_object_outline.py created+47
...@@ -0,0 +1,47 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/object-outline'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16def bodies(read):
17 """Each page's body by title: each paragraph's content kind, with its text."""
18 result = {}
19 for path in sorted(read.glob('page-*.xml')):
20 page = ET.parse(path).getroot()
21 title = page.find('one:Title/one:OE/one:T', ns)
22 body = page.find('one:Outline/one:OEChildren', ns)
23 if title is None or body is None:
24 continue
25 result[title.text] = [
26 next(child.tag.rsplit('}', 1)[1] for child in oe
27 if child.tag.rsplit('}', 1)[1] in ('T', 'Image', 'InsertedFile'))
28 + (':' + re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or '') if oe.find('one:T', ns) is not None else '')
29 for oe in body.findall('one:OE', ns)]
30 return result
31
32
33class ObjectOutlineTest(unittest.TestCase):
34 def test_typing_beside_an_outlines_only_object_reopens_as_onenote_made_it(self):
35 with TemporaryDirectory() as temporary:
36 read = Path(temporary) / 'read'
37 shutil.copytree(FIXTURE / 'cold/read', read)
38 compare(FIXTURE / 'candidate', read)
39 self.assertEqual(bodies(FIXTURE / 'native/read'),
40 {'File alone': ['InsertedFile'], 'Picture alone': ['Image']})
41 onenote = bodies(FIXTURE / 'keys/read')
42 self.assertEqual(onenote, {'File alone': ['InsertedFile', 'T:abc'], 'Picture alone': ['Image', 'T:abc']})
43 self.assertEqual(bodies(FIXTURE / 'cold/read'), onenote)
44
45
46if __name__ == '__main__':
47 unittest.main()
tools/test_printout.py created+41
...@@ -0,0 +1,41 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/printout'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def pictures(read):
16 """The printout page's pictures in order: printout state, printout page and position."""
17 for path in sorted(read.glob('page-*.xml')):
18 page = ET.parse(path).getroot()
19 if page.get('name') == 'Printout':
20 return [(image.get('isPrintOut'), image.get('originalPageNumber'),
21 image.find('one:Position', ns).get('y'))
22 for image in page.findall('one:Image', ns)]
23
24
25class PrintoutTest(unittest.TestCase):
26 def test_edited_printout_reopens_natively(self):
27 for notebook, read in (('native/notebook', 'native/read'), ('candidate', 'cold/read')):
28 with TemporaryDirectory() as temporary:
29 copy = Path(temporary) / 'read'
30 shutil.copytree(FIXTURE / read, copy)
31 compare(FIXTURE / notebook, copy)
32 native = pictures(FIXTURE / 'native/read')
33 self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')])
34 # The first page moved half an inch down stays a printout; the second, deleted and
35 # restored, is the picture OneNote showed of it.
36 cold = pictures(FIXTURE / 'cold/read')
37 self.assertEqual(cold, [('true', '0', str(float(native[0][2]) + 36)), (None, None, native[1][2])])
38
39
40if __name__ == '__main__':
41 unittest.main()
tools/test_recording.py+38
...@@ -70,6 +70,44 @@ class RecordingTest(unittest.TestCase):...@@ -70,6 +70,44 @@ class RecordingTest(unittest.TestCase):
70 self.assertEqual(int.from_bytes(data[20:22], 'little'), 0x11)70 self.assertEqual(int.from_bytes(data[20:22], 'little'), 0x11)
71 self.assertEqual(int.from_bytes(data[24:28], 'little'), 16000)71 self.assertEqual(int.from_bytes(data[24:28], 'little'), 16000)
7272
73 def test_snowbound_s_video_recording_opens_in_onenote_as_a_video_recording(self):
74 read = self.cold('video')
75 _, playlist, links = recorded(read)
76 recording, = playlist
77 shown = [text for text, _, _ in links]
78 self.assertEqual(shown, ['Before recording', '[Recorded.avi]', '',
79 'Video recording started: 5:18 PM Tuesday, September 29, 2026',
80 'First linked note', 'Second linked note'])
81 self.assertEqual(links[1][2], recording)
82 first, second = (link[1] for _, link, _ in links[4:])
83 self.assertTrue(40 <= first < second)
84 entry, data = payload(read, 'Recorded.avi')
85 self.assertEqual(entry['kind'], 'MediaFile')
86 # Motion JPEG and PCM in AVI, which OneNote plays (`video/played-from-note.png`).
87 self.assertEqual(data[:4] + data[8:12], b'RIFFAVI ')
88 self.assertIn(b'vidsMJPG', data[:1024])
89 self.assertIn(b'auds', data[:1024])
90
91 def test_onenote_lists_avi_mpg_and_wmv_files_as_recordings_but_not_mp4_or_mov(self):
92 page = ET.parse(FIXTURE / 'native/read/attached-video.xml').getroot()
93 kinds = {element.get('preferredName'): element.tag.split('}')[1]
94 for element in page.iter()
95 if element.tag.endswith(('}MediaFile', '}InsertedFile'))}
96 self.assertEqual(kinds['mjpeg-pcm.avi'], 'MediaFile')
97 self.assertEqual(kinds['mpeg1.mpg'], 'MediaFile')
98 self.assertEqual(kinds['wmv2.wmv'], 'MediaFile')
99 self.assertEqual(kinds['h264-aac.mp4'], 'InsertedFile')
100 self.assertEqual(kinds['h264-aac.mov'], 'InsertedFile')
101
102 def test_notes_written_while_onenote_s_recording_is_paused_are_not_linked(self):
103 page = ET.parse(FIXTURE / 'native/read/paused-while-recording.xml').getroot()
104 linked = {texts(element)[0]: element.find('one:MediaIndex', ns) is not None
105 for element in page.findall('.//one:OE', ns)
106 if element.find('one:T', ns) is not None and texts(element)[0]}
107 self.assertTrue(linked['Another note here'])
108 self.assertFalse(linked['Typed while paused'])
109 self.assertTrue(linked['After resuming'])
110
73 def test_onenote_s_recording_opens_as_recorded_after_snowbound_deletes_restores_and_links(self):111 def test_onenote_s_recording_opens_as_recorded_after_snowbound_deletes_restores_and_links(self):
74 read = self.cold('edit')112 read = self.cold('edit')
75 _, playlist, links = recorded(read)113 _, playlist, links = recorded(read)
tools/test_release.py created+34
...@@ -0,0 +1,34 @@
1from datetime import datetime, timezone
2from pathlib import Path
3import runpy
4import unittest
5
6release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
7
8
9def utc(text):
10 return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
11
12
13class ReleaseTest(unittest.TestCase):
14 def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self):
15 # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th.
16 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),
17 utc('2026-09-29T06:29:00'), utc('2026-09-28T12:43:00')]
18 self.assertEqual(release['derive'](commits[0], commits), ('2026-09-29', 3))
19 self.assertEqual(release['derive'](commits[3], commits[3:]), ('2026-09-28', 2))
20 self.assertEqual(release['name'](('2026-09-29', 10)), '2026-09-29-r10')
21 self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10))
22 self.assertEqual(release['folder'](('2026-09-29', 10)), '2026-09-29.r10')
23
24 def test_latest_only_moves_forward(self):
25 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}
26 moved = release['newest'](latest, {'macos-aarch64': {}, 'linux-x86_64': {}, 'macos-10.6': {}},
27 ('2026-09-29', 10))
28 self.assertEqual(moved, {'macos-aarch64': '2026-09-29-r10', 'linux-x86_64': '2026-09-30-r1',
29 'macos-10.6': '2026-09-29-r10'})
30 self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest)
31
32
33if __name__ == '__main__':
34 unittest.main()
tools/test_table_tags.py created+47
...@@ -0,0 +1,47 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/table-tags'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def tables(read):
16 """Each page's tables by title: their first cell texts and tags as (name, completed)."""
17 result = {}
18 for path in sorted(read.glob('page-*.xml')):
19 page = ET.parse(path).getroot()
20 title = page.find('one:Title/one:OE/one:T', ns)
21 if title is None:
22 continue
23 names = {d.get('index'): d.get('name') for d in page.findall('one:TagDef', ns)}
24 result[title.text] = [
25 ([cell.find('.//one:T', ns).text for cell in oe.find('one:Table', ns).iter('{%s}Cell' % ns['one'])],
26 [(names[tag.get('index')], tag.get('completed')) for tag in oe.findall('one:Tag', ns)])
27 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:Table', ns) is not None]
28 return result
29
30
31class TableTagsTest(unittest.TestCase):
32 def test_edited_tagged_tables_reopen_natively(self):
33 with TemporaryDirectory() as temporary:
34 read = Path(temporary) / 'read'
35 shutil.copytree(FIXTURE / 'cold/read', read)
36 compare(FIXTURE / 'candidate', read)
37 native = tables(FIXTURE / 'native/read')
38 cold = tables(FIXTURE / 'cold/read')
39 self.assertEqual(native['Check a table'], [(['Checked by a click', 'B'], [('To Do', 'false')])])
40 self.assertEqual(cold['Check a table'], [(['Checked by a click', 'B'], [('To Do', 'true')])])
41 edited = native['Edit a tagged table']
42 self.assertEqual(cold['Edit a tagged table'], [(['Now '] + edited[0][0][1:], edited[0][1])])
43 self.assertEqual(cold['Delete and undo'], native['Delete and undo'])
44
45
46if __name__ == '__main__':
47 unittest.main()
tools/verify-document.py+10
...@@ -9,6 +9,7 @@ from PIL import Image...@@ -9,6 +9,7 @@ from PIL import Image
9from datetime import datetime, timezone9from datetime import datetime, timezone
10import json10import json
11import math11import math
12import re
12from pathlib import Path, PureWindowsPath13from pathlib import Path, PureWindowsPath
13from zoneinfo import ZoneInfo14from zoneinfo import ZoneInfo
14import subprocess15import subprocess
...@@ -156,6 +157,12 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a...@@ -156,6 +157,12 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a
156 data = assets[json.dumps(container['reference'], sort_keys=True)]157 data = assets[json.dumps(container['reference'], sort_keys=True)]
157 if kind['type'] == 'Image':158 if kind['type'] == 'Image':
158 expected = base64.b64decode(native.find('one:Data', ns).text)159 expected = base64.b64decode(native.find('one:Data', ns).text)
160 if kind['printout'] and data.startswith(b'PK\x03\x04'):
161 # A printout page stores its XPS package; the read shows OneNote's rendering
162 # of it, kept as the picture's WebPictureContainer14.
163 web = next(field['value']['Objects'][0] for group in node['extra']
164 for field in group if field['id'] == 0x200034c8)
165 data = assets[json.dumps(space['nodes'][web]['kind']['reference'], sort_keys=True)]
159 if data != expected:166 if data != expected:
160 actual_image = Image.open(BytesIO(data)).convert('RGBA')167 actual_image = Image.open(BytesIO(data)).convert('RGBA')
161 expected_image = Image.open(BytesIO(expected)).convert('RGBA')168 expected_image = Image.open(BytesIO(expected)).convert('RGBA')
...@@ -256,6 +263,9 @@ def exported_links(runs):...@@ -256,6 +263,9 @@ def exported_links(runs):
256 over http, and URL text it finds in a page's text is a link, a removed link's included263 over http, and URL text it finds in a page's text is a link, a removed link's included
257 (`corpus/link-edit/native-typed`)."""264 (`corpus/link-edit/native-typed`)."""
258 def href(link):265 def href(link):
266 # A file address naming a drive gains the third slash of its URL.
267 if link is not None and re.match(r'file://[A-Za-z]:', link):
268 return 'file:///' + link[len('file://'):]
259 if link is None or ':' in link.split('/')[0] or link.startswith('\\\\'):269 if link is None or ':' in link.split('/')[0] or link.startswith('\\\\'):
260 return link270 return link
261 return 'http://' + link271 return 'http://' + link