| author | |
| committer | |
| log | 0aad32984f17248dab49bcb4b36a5342a8fcf036 |
| tree | 2a890e4d21a13f59fc4818d56d582c29f374aafe |
| parent | 77c1c97d315c0c636926287befc0f1202290cef7 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.5619 files changed, 27459 insertions(+), 1520 deletions(-)
Cargo.lock+145-1| ... | ... | @@ -451,6 +451,12 @@ version = "0.22.1" |
| 451 | 451 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 452 | 452 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 453 | 453 | |
| 454 | [[package]] | |
| 455 | name = "base64" | |
| 456 | version = "0.23.1" | |
| 457 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | |
| 459 | ||
| 454 | 460 | [[package]] |
| 455 | 461 | name = "bit-set" |
| 456 | 462 | version = "0.10.0" |
| ... | ... | @@ -964,6 +970,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" |
| 964 | 970 | name = "draw" |
| 965 | 971 | version = "0.1.0" |
| 966 | 972 | dependencies = [ |
| 973 | "base64 0.22.1", | |
| 967 | 974 | "bytemuck", |
| 968 | 975 | "image", |
| 969 | 976 | "linebender_resource_handle", |
| ... | ... | @@ -1237,6 +1244,17 @@ dependencies = [ |
| 1237 | 1244 | "windows-link", |
| 1238 | 1245 | ] |
| 1239 | 1246 | |
| 1247 | [[package]] | |
| 1248 | name = "getrandom" | |
| 1249 | version = "0.2.17" | |
| 1250 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1251 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 1252 | dependencies = [ | |
| 1253 | "cfg-if", | |
| 1254 | "libc", | |
| 1255 | "wasi", | |
| 1256 | ] | |
| 1257 | ||
| 1240 | 1258 | [[package]] |
| 1241 | 1259 | name = "getrandom" |
| 1242 | 1260 | version = "0.3.4" |
| ... | ... | @@ -1400,6 +1418,22 @@ dependencies = [ |
| 1400 | 1418 | "digest", |
| 1401 | 1419 | ] |
| 1402 | 1420 | |
| 1421 | [[package]] | |
| 1422 | name = "http" | |
| 1423 | version = "1.5.0" | |
| 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1425 | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" | |
| 1426 | dependencies = [ | |
| 1427 | "bytes", | |
| 1428 | "itoa", | |
| 1429 | ] | |
| 1430 | ||
| 1431 | [[package]] | |
| 1432 | name = "httparse" | |
| 1433 | version = "1.10.1" | |
| 1434 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1435 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 1436 | ||
| 1403 | 1437 | [[package]] |
| 1404 | 1438 | name = "hybrid-array" |
| 1405 | 1439 | version = "0.4.14" |
| ... | ... | @@ -1885,6 +1919,8 @@ dependencies = [ |
| 1885 | 1919 | "canvas", |
| 1886 | 1920 | "draw", |
| 1887 | 1921 | "notebook", |
| 1922 | "objc2 0.5.2", | |
| 1923 | "objc2-foundation 0.2.2", | |
| 1888 | 1924 | "onestore", |
| 1889 | 1925 | "parley", |
| 1890 | 1926 | "pollster", |
| ... | ... | @@ -1990,6 +2026,7 @@ name = "notebook" |
| 1990 | 2026 | version = "0.1.0" |
| 1991 | 2027 | dependencies = [ |
| 1992 | 2028 | "libc", |
| 2029 | "nix", | |
| 1993 | 2030 | "onestore", |
| 1994 | 2031 | "rusqlite", |
| 1995 | 2032 | "serde", |
| ... | ... | @@ -2361,7 +2398,7 @@ name = "onestore" |
| 2361 | 2398 | version = "0.1.0" |
| 2362 | 2399 | dependencies = [ |
| 2363 | 2400 | "aes", |
| 2364 | "base64", | |
| 2401 | "base64 0.22.1", | |
| 2365 | 2402 | "bumpalo", |
| 2366 | 2403 | "cbc", |
| 2367 | 2404 | "getrandom 0.4.3", |
| ... | ... | @@ -2849,6 +2886,20 @@ dependencies = [ |
| 2849 | 2886 | "bytemuck", |
| 2850 | 2887 | ] |
| 2851 | 2888 | |
| 2889 | [[package]] | |
| 2890 | name = "ring" | |
| 2891 | version = "0.17.14" | |
| 2892 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2893 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | |
| 2894 | dependencies = [ | |
| 2895 | "cc", | |
| 2896 | "cfg-if", | |
| 2897 | "getrandom 0.2.17", | |
| 2898 | "libc", | |
| 2899 | "untrusted", | |
| 2900 | "windows-sys 0.52.0", | |
| 2901 | ] | |
| 2902 | ||
| 2852 | 2903 | [[package]] |
| 2853 | 2904 | name = "roxmltree" |
| 2854 | 2905 | version = "0.21.1" |
| ... | ... | @@ -2924,6 +2975,41 @@ dependencies = [ |
| 2924 | 2975 | "windows-sys 0.61.2", |
| 2925 | 2976 | ] |
| 2926 | 2977 | |
| 2978 | [[package]] | |
| 2979 | name = "rustls" | |
| 2980 | version = "0.23.45" | |
| 2981 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2982 | checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" | |
| 2983 | dependencies = [ | |
| 2984 | "log", | |
| 2985 | "once_cell", | |
| 2986 | "ring", | |
| 2987 | "rustls-pki-types", | |
| 2988 | "rustls-webpki", | |
| 2989 | "subtle", | |
| 2990 | "zeroize", | |
| 2991 | ] | |
| 2992 | ||
| 2993 | [[package]] | |
| 2994 | name = "rustls-pki-types" | |
| 2995 | version = "1.15.1" | |
| 2996 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2997 | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" | |
| 2998 | dependencies = [ | |
| 2999 | "zeroize", | |
| 3000 | ] | |
| 3001 | ||
| 3002 | [[package]] | |
| 3003 | name = "rustls-webpki" | |
| 3004 | version = "0.103.15" | |
| 3005 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3006 | checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" | |
| 3007 | dependencies = [ | |
| 3008 | "ring", | |
| 3009 | "rustls-pki-types", | |
| 3010 | "untrusted", | |
| 3011 | ] | |
| 3012 | ||
| 2927 | 3013 | [[package]] |
| 2928 | 3014 | name = "rustversion" |
| 2929 | 3015 | version = "1.0.23" |
| ... | ... | @@ -3232,8 +3318,10 @@ name = "snowbound" |
| 3232 | 3318 | version = "0.1.0" |
| 3233 | 3319 | dependencies = [ |
| 3234 | 3320 | "accesskit", |
| 3321 | "accesskit_consumer", | |
| 3235 | 3322 | "accesskit_winit", |
| 3236 | 3323 | "arboard", |
| 3324 | "block2 0.5.1", | |
| 3237 | 3325 | "canvas", |
| 3238 | 3326 | "draw", |
| 3239 | 3327 | "fontique", |
| ... | ... | @@ -3247,11 +3335,16 @@ dependencies = [ |
| 3247 | 3335 | "parley", |
| 3248 | 3336 | "png", |
| 3249 | 3337 | "pollster", |
| 3338 | "ring", | |
| 3250 | 3339 | "sctk-adwaita", |
| 3251 | 3340 | "serde", |
| 3252 | 3341 | "serde_json", |
| 3253 | 3342 | "smithay-clipboard", |
| 3254 | 3343 | "ui", |
| 3344 | "ureq", | |
| 3345 | "wayland-backend", | |
| 3346 | "wayland-client", | |
| 3347 | "wayland-protocols", | |
| 3255 | 3348 | "wgpu", |
| 3256 | 3349 | "winit", |
| 3257 | 3350 | "zbus", |
| ... | ... | @@ -3616,6 +3709,8 @@ dependencies = [ |
| 3616 | 3709 | name = "ui" |
| 3617 | 3710 | version = "0.1.0" |
| 3618 | 3711 | dependencies = [ |
| 3712 | "accesskit", | |
| 3713 | "accesskit_consumer", | |
| 3619 | 3714 | "draw", |
| 3620 | 3715 | "nucleo-matcher", |
| 3621 | 3716 | "parley", |
| ... | ... | @@ -3653,6 +3748,40 @@ dependencies = [ |
| 3653 | 3748 | "ctutils", |
| 3654 | 3749 | ] |
| 3655 | 3750 | |
| 3751 | [[package]] | |
| 3752 | name = "untrusted" | |
| 3753 | version = "0.9.0" | |
| 3754 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3755 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | |
| 3756 | ||
| 3757 | [[package]] | |
| 3758 | name = "ureq" | |
| 3759 | version = "3.4.2" | |
| 3760 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3761 | checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" | |
| 3762 | dependencies = [ | |
| 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]] | |
| 3774 | name = "ureq-proto" | |
| 3775 | version = "0.6.4" | |
| 3776 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3777 | checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" | |
| 3778 | dependencies = [ | |
| 3779 | "base64 0.23.1", | |
| 3780 | "http", | |
| 3781 | "httparse", | |
| 3782 | "log", | |
| 3783 | ] | |
| 3784 | ||
| 3656 | 3785 | [[package]] |
| 3657 | 3786 | name = "usvg" |
| 3658 | 3787 | version = "0.48.1" |
| ... | ... | @@ -3678,6 +3807,12 @@ version = "1.0.5" |
| 3678 | 3807 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3679 | 3808 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" |
| 3680 | 3809 | |
| 3810 | [[package]] | |
| 3811 | name = "utf8-zero" | |
| 3812 | version = "0.8.1" | |
| 3813 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3814 | checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" | |
| 3815 | ||
| 3681 | 3816 | [[package]] |
| 3682 | 3817 | name = "utf8_iter" |
| 3683 | 3818 | version = "1.0.4" |
| ... | ... | @@ -3942,6 +4077,15 @@ dependencies = [ |
| 3942 | 4077 | "wasm-bindgen", |
| 3943 | 4078 | ] |
| 3944 | 4079 | |
| 4080 | [[package]] | |
| 4081 | name = "webpki-roots" | |
| 4082 | version = "1.0.9" | |
| 4083 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4084 | checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" | |
| 4085 | dependencies = [ | |
| 4086 | "rustls-pki-types", | |
| 4087 | ] | |
| 4088 | ||
| 3945 | 4089 | [[package]] |
| 3946 | 4090 | name = "weezl" |
| 3947 | 4091 | version = "0.1.12" |
LICENSE created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | ISC License | |
| 2 | ||
| 3 | Copyright (c) 2026 clover caruso | |
| 4 | ||
| 5 | Permission to use, copy, modify, and/or distribute this software for any | |
| 6 | purpose with or without fee is hereby granted, provided that the above | |
| 7 | copyright notice and this permission notice appear in all copies. | |
| 8 | ||
| 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
| 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
| 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
| 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
| 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
| 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
apps/ios/Info.plist+12| ... | ... | @@ -8,6 +8,18 @@ |
| 8 | 8 | 	<true/> |
| 9 | 9 | 	<key>NSCameraUsageDescription</key> |
| 10 | 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 | 23 | 	<key>NSLocalNetworkUsageDescription</key> |
| 12 | 24 | 	<string>Snowbound opens notebooks on file servers on your network.</string> |
| 13 | 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 | 198 | 			isa = XCBuildConfiguration; |
| 199 | 199 | 			buildSettings = { |
| 200 | 200 | 				ARCHS = arm64; |
| 201 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; | |
| 201 | 202 | 				CURRENT_PROJECT_VERSION = 1; |
| 202 | 203 | 				GENERATE_INFOPLIST_FILE = YES; |
| 203 | 204 | 				HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include"; |
| ... | ... | @@ -219,7 +220,7 @@ |
| 219 | 220 | 					"-framework", |
| 220 | 221 | 					CoreText, |
| 221 | 222 | 				); |
| 222 | 				PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound; | |
| 223 | 				PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound; | |
| 223 | 224 | 				PRODUCT_NAME = "$(TARGET_NAME)"; |
| 224 | 225 | 				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
| 225 | 226 | 				SWIFT_EMIT_LOC_STRINGS = NO; |
| ... | ... | @@ -233,6 +234,7 @@ |
| 233 | 234 | 			isa = XCBuildConfiguration; |
| 234 | 235 | 			buildSettings = { |
| 235 | 236 | 				ARCHS = arm64; |
| 237 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; | |
| 236 | 238 | 				CURRENT_PROJECT_VERSION = 1; |
| 237 | 239 | 				GENERATE_INFOPLIST_FILE = YES; |
| 238 | 240 | 				HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include"; |
| ... | ... | @@ -254,7 +256,7 @@ |
| 254 | 256 | 					"-framework", |
| 255 | 257 | 					CoreText, |
| 256 | 258 | 				); |
| 257 | 				PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound; | |
| 259 | 				PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound; | |
| 258 | 260 | 				PRODUCT_NAME = "$(TARGET_NAME)"; |
| 259 | 261 | 				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
| 260 | 262 | 				SWIFT_EMIT_LOC_STRINGS = NO; |
apps/ios/Snowbound/App.swift+25-1| ... | ... | @@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 19 | 19 | _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? |
| 20 | 20 | ) -> Bool { |
| 21 | 21 | sb_set_coordinator(coordinate) |
| 22 | ICloud.start() | |
| 22 | 23 | sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } } |
| 23 | 24 | #if DEBUG |
| 24 | 25 | Presenter.watch() |
| ... | ... | @@ -45,7 +46,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 45 | 46 | |
| 46 | 47 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. |
| 47 | 48 | private struct Place: Codable { |
| 48 | let notebook: UUID | |
| 49 | let notebook: String | |
| 49 | 50 | let section: String |
| 50 | 51 | let page: String? |
| 51 | 52 | |
| ... | ... | @@ -90,6 +91,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 90 | 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 | } |
| 94 | 100 | |
| 95 | 101 | /// Reopens the section and page shown last, or the scripted one. |
| ... | ... | @@ -171,6 +177,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 171 | 177 | Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page) |
| 172 | 178 | } |
| 173 | 179 | |
| 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 | 189 | /// Adds a page to the open section and opens it, its title ready for typing. |
| 175 | 190 | func newPage(subpage: Bool = false) { |
| 176 | 191 | guard let section = pages.section else { return } |
| ... | ... | @@ -194,6 +209,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 194 | 209 | /// Composition ends and every edit is stored and published while the system allows. |
| 195 | 210 | func sceneDidEnterBackground(_ scene: UIScene) { |
| 196 | 211 | NotificationCenter.default.post(name: PageViewController.leaving, object: nil) |
| 212 | for notebook in Notebooks.all { notebook.pause() } | |
| 197 | 213 | let sections = Section.all |
| 198 | 214 | guard !sections.isEmpty, background == .invalid else { return } |
| 199 | 215 | background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in |
| ... | ... | @@ -211,8 +227,16 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 211 | 227 | background = .invalid |
| 212 | 228 | } |
| 213 | 229 | |
| 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 | 236 | /// Changes made elsewhere while away show at once. |
| 215 | 237 | func sceneWillEnterForeground(_ scene: UIScene) { |
| 238 | for notebook in Notebooks.all { notebook.resume() } | |
| 239 | notebooks.rescan() | |
| 216 | 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 | 628 | } |
| 629 | 629 | } |
| 630 | 630 | |
| 631 | /// The Pencil, or a drawing finger, comes down at `point` in the scroll view's bounds. | |
| 632 | func inkPressed(_ point: CGPoint) { | |
| 631 | /// The Pencil, or a drawing finger, comes down at `sample`'s point in the scroll view's bounds. | |
| 632 | func inkPressed(_ sample: InkSample) { | |
| 633 | 633 | guard let handle else { return } |
| 634 | 634 | // Drawing puts the keyboard away, as Notes does. |
| 635 | 635 | if isFirstResponder { _ = resignFirstResponder() } |
| 636 | 636 | use(inkTool) |
| 637 | 637 | editMenu.dismissMenu() |
| 638 | let point = visible(point) | |
| 638 | let point = visible(sample.point) | |
| 639 | sb_view_pressure(handle, sample.pressure) | |
| 639 | 640 | edit { sb_view_press(handle, Float(point.x), Float(point.y)) } |
| 640 | 641 | } |
| 641 | 642 | |
| 642 | func inkMoved(_ points: ArraySlice<CGPoint>) { | |
| 643 | func inkMoved(_ samples: ArraySlice<InkSample>) { | |
| 643 | 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 | 650 | dirty = true |
| 646 | 651 | } |
| 647 | 652 | |
| ... | ... | @@ -664,7 +669,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 664 | 669 | _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, |
| 665 | 670 | suggestedActions: [UIMenuElement] |
| 666 | 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 | 676 | return UIMenu(children: [ |
| 669 | 677 | UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in |
| 670 | 678 | self?.deleteInk() |
| ... | ... | @@ -828,7 +836,32 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 828 | 836 | self?.apply(10) |
| 829 | 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 | } |
| 833 | 866 | |
| 834 | 867 | // MARK: Page |
apps/ios/Snowbound/ICloud.swift created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import 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. | |
| 5 | private 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. | |
| 19 | private 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. | |
| 45 | enum 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 | 43 | } |
| 44 | 44 | } |
| 45 | 45 | |
| 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. | |
| 48 | typealias InkSample = (point: CGPoint, pressure: Float) | |
| 49 | ||
| 46 | 50 | /// Follows one touch from the moment it lands, gathering every point the hardware sampled, |
| 47 | 51 | /// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two |
| 48 | 52 | /// fingers scroll; a hand resting beside the Pencil is ignored. |
| 49 | 53 | final class InkGesture: UIGestureRecognizer { |
| 50 | /// Points the touch passed since the action last took them, in the view's bounds. | |
| 51 | var points: [CGPoint] = [] | |
| 54 | /// Points the touch passed since the action last took them. | |
| 55 | var points: [InkSample] = [] | |
| 52 | 56 | private var tracked: UITouch? |
| 53 | 57 | |
| 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 | 63 | override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) { |
| 55 | 64 | for touch in touches { |
| 56 | 65 | if tracked == nil { |
| 57 | 66 | tracked = touch |
| 58 | points = [touch.preciseLocation(in: view)] | |
| 67 | points = [sample(touch)] | |
| 59 | 68 | state = .began |
| 60 | 69 | } else if tracked?.type == .direct { |
| 61 | 70 | state = .cancelled |
| ... | ... | @@ -67,13 +76,13 @@ final class InkGesture: UIGestureRecognizer { |
| 67 | 76 | |
| 68 | 77 | override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) { |
| 69 | 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 | 80 | state = .changed |
| 72 | 81 | } |
| 73 | 82 | |
| 74 | 83 | override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) { |
| 75 | 84 | guard let tracked, touches.contains(tracked) else { return } |
| 76 | points.append(tracked.preciseLocation(in: view)) | |
| 85 | points.append(sample(tracked)) | |
| 77 | 86 | state = .ended |
| 78 | 87 | } |
| 79 | 88 |
apps/ios/Snowbound/Lists.swift+551-154| ... | ... | @@ -1,21 +1,40 @@ |
| 1 | 1 | import UIKit |
| 2 | 2 | import UniformTypeIdentifiers |
| 3 | 3 | |
| 4 | /// Every open notebook with its sections, as Notes lists each account's folders. | |
| 5 | final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate { | |
| 6 | private enum Item { | |
| 7 | case group(String, depth: Int) | |
| 8 | case section(Tab, depth: Int) | |
| 9 | case opening | |
| 10 | case problem(String) | |
| 11 | } | |
| 12 | ||
| 13 | private var items: [[Item]] = [] | |
| 14 | private var selected: (notebook: UUID, path: String)? | |
| 4 | /// The places notebooks are kept, each listing its notebooks with their sections, as Files | |
| 5 | /// lists its locations and Notes each account's folders. | |
| 6 | final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate { | |
| 7 | private enum Location: Hashable { | |
| 8 | /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on. | |
| 9 | case icloud | |
| 10 | /// Snowbound's Documents, which Files shows as Snowbound's folder. | |
| 11 | case onDevice | |
| 12 | /// Notebooks opened from Files or a server. | |
| 13 | case elsewhere | |
| 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 | 32 | var onOpen: ((Tab, Notebook) -> Void)? |
| 16 | 33 | |
| 34 | private static let device = "On My \(UIDevice.current.model)" | |
| 35 | ||
| 17 | 36 | init() { |
| 18 | super.init(style: .insetGrouped) | |
| 37 | super.init(collectionViewLayout: UICollectionViewFlowLayout()) | |
| 19 | 38 | title = "Notebooks" |
| 20 | 39 | navigationItem.largeTitleDisplayMode = .always |
| 21 | 40 | } |
| ... | ... | @@ -24,23 +43,79 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele |
| 24 | 43 | |
| 25 | 44 | override func viewDidLoad() { |
| 26 | 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 | 74 | let add = UIMenu(children: [ |
| 29 | UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in | |
| 30 | self?.openFromFiles() | |
| 31 | }, | |
| 32 | UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in | |
| 33 | self?.connect() | |
| 34 | }, | |
| 35 | UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in | |
| 36 | guard let self else { return } | |
| 37 | Author.ask(from: self) {} | |
| 38 | }, | |
| 75 | UIDeferredMenuElement.uncached { [weak self] provide in | |
| 76 | guard let self else { return provide([]) } | |
| 77 | var actions: [UIMenuElement] = [] | |
| 78 | if ICloud.documents != nil || Notebooks.showsOnDevice { | |
| 79 | actions.append( | |
| 80 | UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in | |
| 81 | self?.newNotebook(inCloud: ICloud.documents != nil) | |
| 82 | }) | |
| 83 | } | |
| 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 | 107 | navigationItem.rightBarButtonItem = UIBarButtonItem( |
| 41 | 108 | title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) |
| 42 | 109 | navigationItem.searchController = SearchViewController.controller() |
| 43 | 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 | } |
| 45 | 120 | |
| 46 | 121 | override func viewWillAppear(_ animated: Bool) { |
| ... | ... | @@ -49,47 +124,403 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele |
| 49 | 124 | navigationController?.setToolbarHidden(false, animated: false) |
| 50 | 125 | } |
| 51 | 126 | |
| 127 | private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } | |
| 128 | ||
| 52 | 129 | func reload() { |
| 53 | items = Notebooks.all.map { notebook in | |
| 54 | if notebook.handle == nil { | |
| 55 | return [notebook.problem.map(Item.problem) ?? .opening] | |
| 56 | } | |
| 57 | var rows: [Item] = [] | |
| 58 | var group = "" | |
| 59 | for tab in notebook.tabs { | |
| 60 | if tab.group != group { | |
| 61 | // Each group level not yet shown gets its own row. | |
| 62 | let parts = tab.group.split(separator: "/").map(String.init) | |
| 63 | let shown = group.split(separator: "/").map(String.init) | |
| 64 | let common = zip(parts, shown).prefix { $0 == $1 }.count | |
| 65 | for depth in common..<parts.count { rows.append(.group(parts[depth], depth: depth)) } | |
| 66 | group = tab.group | |
| 130 | guard isViewLoaded else { return } | |
| 131 | let cloud: [(Location, [Notebook], [Item])] = | |
| 132 | ICloud.documents != nil | |
| 133 | ? [(.icloud, Notebooks.inCloud, [.newInCloud])] | |
| 134 | : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] | |
| 135 | let locations: [(Location, [Notebook], [Item])] = | |
| 136 | cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) | |
| 137 | + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])] | |
| 138 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() | |
| 139 | sections.appendSections(locations.map(\.0)) | |
| 140 | dataSource.apply(sections, animatingDifferences: false) | |
| 141 | for (location, notebooks, actions) in locations { | |
| 142 | var list = NSDiffableDataSourceSectionSnapshot<Item>() | |
| 143 | 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 rows | |
| 164 | list.expand(list.items.filter { !collapsed.contains($0) }) | |
| 165 | dataSource.apply(list, to: location, animatingDifferences: false) | |
| 71 | 166 | } |
| 72 | tableView.reloadData() | |
| 73 | 167 | showSelection() |
| 74 | var empty = UIContentUnavailableConfiguration.empty() | |
| 75 | empty.text = "No Notebooks" | |
| 76 | empty.secondaryText = "Open a OneNote notebook from Files or a server." | |
| 77 | contentUnavailableConfiguration = items.isEmpty ? empty : nil | |
| 168 | } | |
| 169 | ||
| 170 | /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs | |
| 171 | /// 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 | } |
| 79 | 186 | |
| 80 | 187 | func select(_ tab: Tab, of notebook: Notebook) { |
| 81 | selected = (notebook.id, tab.path) | |
| 188 | selected = .section(notebook: notebook.id, path: tab.path) | |
| 82 | 189 | showSelection() |
| 83 | 190 | } |
| 84 | 191 | |
| 85 | 192 | /// Marks the open section, as the sidebar of a wide window keeps it. |
| 86 | 193 | private func showSelection() { |
| 87 | guard let selected, let section = Notebooks.all.firstIndex(where: { $0.id == selected.notebook }), | |
| 88 | let row = items[section].firstIndex(where: { | |
| 89 | if case .section(let tab, _) = $0 { tab.path == selected.path } else { false } | |
| 90 | }) | |
| 194 | guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return } | |
| 195 | collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) | |
| 196 | } | |
| 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 | 522 | else { return } |
| 92 | tableView.selectRow(at: IndexPath(row: row, section: section), animated: false, scrollPosition: .none) | |
| 523 | onOpen?(tab, notebook) | |
| 93 | 524 | } |
| 94 | 525 | |
| 95 | 526 | private func add(_ notebook: Notebook) { |
| ... | ... | @@ -102,7 +533,66 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele |
| 102 | 533 | } |
| 103 | 534 | } |
| 104 | 535 | |
| 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 | 596 | let picker = UIDocumentPickerViewController( |
| 107 | 597 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) |
| 108 | 598 | picker.delegate = self |
| ... | ... | @@ -145,109 +635,6 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele |
| 145 | 635 | }) |
| 146 | 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 | } |
| 252 | 639 | |
| 253 | 640 | extension UIFont { |
| ... | ... | @@ -308,6 +695,16 @@ final class PagesViewController: UITableViewController { |
| 308 | 695 | navigationController?.setToolbarHidden(true, animated: false) |
| 309 | 696 | } |
| 310 | 697 | |
| 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 | 708 | func failed(_ tab: Tab, _ problem: String?) { |
| 312 | 709 | var empty = UIContentUnavailableConfiguration.empty() |
| 313 | 710 | empty.text = "Can’t Open Section" |
apps/ios/Snowbound/Notebooks.swift+176-30| ... | ... | @@ -109,6 +109,8 @@ enum Source: Codable, Equatable { |
| 109 | 109 | case files(bookmark: Data) |
| 110 | 110 | /// A folder in the app's Documents, which Files shows as Snowbound's. |
| 111 | 111 | case documents(path: String) |
| 112 | /// A folder in Snowbound's folder in iCloud Drive (`ICloud.documents`). | |
| 113 | case icloud(path: String) | |
| 112 | 114 | /// A notebook folder on an SMB share, opened through Snowbound's own client. |
| 113 | 115 | case server(Server) |
| 114 | 116 | /// A folder or section at an absolute path, for scripted runs. |
| ... | ... | @@ -124,6 +126,12 @@ struct Tab: Decodable, Equatable { |
| 124 | 126 | let group: String |
| 125 | 127 | let color: [UInt8] |
| 126 | 128 | 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? | |
| 127 | 135 | |
| 128 | 136 | var uiColor: UIColor { |
| 129 | 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 | 158 | |
| 151 | 159 | /// An open notebook: its Rust library and the sections it lists. |
| 152 | 160 | final class Notebook { |
| 153 | let id: UUID | |
| 161 | /// A UUID, or `Documents/` and the folder's name for a notebook On My iPhone. | |
| 162 | let id: String | |
| 154 | 163 | let source: Source |
| 155 | 164 | private(set) var name: String |
| 156 | 165 | private(set) var handle: OpaquePointer? |
| ... | ... | @@ -161,8 +170,12 @@ final class Notebook { |
| 161 | 170 | private var accessed: URL? |
| 162 | 171 | /// Reports other apps' changes to a folder on this device to the library. |
| 163 | 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") | |
| 164 | 177 | |
| 165 | init(id: UUID = UUID(), name: String, source: Source) { | |
| 178 | init(id: String = UUID().uuidString, name: String, source: Source) { | |
| 166 | 179 | self.id = id |
| 167 | 180 | self.name = name |
| 168 | 181 | self.source = source |
| ... | ... | @@ -205,6 +218,16 @@ final class Notebook { |
| 205 | 218 | let url = documentsDirectory.appendingPathComponent(path) |
| 206 | 219 | let library = sb_library_open(url.path, cacheDirectory.path, true, &error) |
| 207 | 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 | 231 | case .path(let path): |
| 209 | 232 | let url = URL(fileURLWithPath: path) |
| 210 | 233 | let local = onThisDevice(url) |
| ... | ... | @@ -233,7 +256,8 @@ final class Notebook { |
| 233 | 256 | var location: String { |
| 234 | 257 | switch source { |
| 235 | 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 | 261 | case .server(let server): |
| 238 | 262 | "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))" |
| 239 | 263 | case .path(let path): path |
| ... | ... | @@ -249,9 +273,51 @@ final class Notebook { |
| 249 | 273 | let pointer = Int(bitPattern: handle) |
| 250 | 274 | background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in |
| 251 | 275 | if let tabs { self.tabs = tabs } |
| 276 | download() | |
| 252 | 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 | } |
| 256 | 322 | |
| 257 | 323 | /// 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 | 347 | NSFileCoordinator.addFilePresenter(self) |
| 282 | 348 | } |
| 283 | 349 | |
| 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 | 356 | func stop() { |
| 286 | 357 | NSFileCoordinator.removeFilePresenter(self) |
| 287 | 358 | presentedItemOperationQueue.waitUntilAllOperationsAreFinished() |
| ... | ... | @@ -290,6 +361,8 @@ final class FolderPresenter: NSObject, NSFilePresenter { |
| 290 | 361 | func presentedItemDidChange() { touched("") } |
| 291 | 362 | func presentedSubitemDidChange(at url: URL) { touched(url) } |
| 292 | 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 | 366 | func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) { |
| 294 | 367 | touched(oldURL) |
| 295 | 368 | touched(newURL) |
| ... | ... | @@ -309,59 +382,132 @@ final class FolderPresenter: NSObject, NSFilePresenter { |
| 309 | 382 | } |
| 310 | 383 | } |
| 311 | 384 | |
| 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. | |
| 313 | 387 | enum Notebooks { |
| 314 | 388 | private struct Entry: Codable { |
| 315 | let id: UUID | |
| 389 | let id: String | |
| 316 | 390 | let name: String |
| 317 | 391 | let source: Source |
| 318 | 392 | } |
| 319 | 393 | |
| 320 | 394 | private static let key = "notebooks" |
| 321 | private(set) static var all: [Notebook] = [] | |
| 322 | ||
| 323 | /// The kept notebooks, or on first launch a copy of the bundled sample in Documents. | |
| 395 | private static let hidden = "hidesOnDevice" | |
| 396 | private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] | |
| 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 | 411 | static func load() { |
| 325 | if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { | |
| 326 | all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))] | |
| 412 | if let scripted { | |
| 413 | elsewhere = [ | |
| 414 | Notebook( | |
| 415 | name: URL(fileURLWithPath: scripted).deletingPathExtension().lastPathComponent, | |
| 416 | source: .path(scripted)) | |
| 417 | ] | |
| 327 | 418 | return |
| 328 | 419 | } |
| 329 | 420 | if let data = UserDefaults.standard.data(forKey: key), |
| 330 | 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) } | |
| 333 | return | |
| 423 | elsewhere = entries.compactMap { entry in | |
| 424 | 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") | |
| 336 | if !FileManager.default.fileExists(atPath: sample.path), | |
| 337 | let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil) | |
| 338 | { | |
| 339 | try? FileManager.default.copyItem(at: bundled, to: sample) | |
| 446 | scan() | |
| 447 | } | |
| 448 | ||
| 449 | /// Lists Documents again, keeping the notebooks still there; returns those new to the list. | |
| 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"))] | |
| 342 | // The notebook the first version remembered from Files. | |
| 343 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { | |
| 344 | var stale = false | |
| 345 | let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))? | |
| 346 | .deletingPathExtension().lastPathComponent | |
| 347 | all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark))) | |
| 456 | var added: [Notebook] = [] | |
| 457 | onDevice = notebooks(in: documentsDirectory).map { name in | |
| 458 | if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept } | |
| 459 | let notebook = Notebook( | |
| 460 | id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name)) | |
| 461 | added.append(notebook) | |
| 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 | } |
| 351 | 497 | |
| 352 | 498 | static func add(_ notebook: Notebook) { |
| 353 | all.append(notebook) | |
| 499 | elsewhere.append(notebook) | |
| 354 | 500 | save() |
| 355 | 501 | } |
| 356 | 502 | |
| 357 | 503 | static func remove(_ notebook: Notebook) { |
| 358 | all.removeAll { $0 === notebook } | |
| 504 | elsewhere.removeAll { $0 === notebook } | |
| 359 | 505 | save() |
| 360 | 506 | } |
| 361 | 507 | |
| 362 | 508 | private static func save() { |
| 363 | guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return } | |
| 364 | let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) } | |
| 509 | guard scripted == nil else { return } | |
| 510 | let entries = elsewhere.map { Entry(id: $0.id, name: $0.name, source: $0.source) } | |
| 365 | 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 | 38 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| 39 | 39 | tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) |
| 40 | 40 | case "pencil": |
| 41 | // Points as `x,y`, or `x,y,pressure` for the Pencil's pressure from 0 to 1. | |
| 41 | 42 | let points = argument.split(separator: ";").map { |
| 42 | 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 | 47 | inkPressed(points[0]) |
| 46 | 48 | inkMoved(points.dropFirst()) |
| ... | ... | @@ -80,6 +82,12 @@ extension CanvasView { |
| 80 | 82 | case "rotate": |
| 81 | 83 | let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight |
| 82 | 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 | 91 | case "done": _ = resignFirstResponder() |
| 84 | 92 | case "selectall": selectAll(nil) |
| 85 | 93 | case "format": apply(UInt8(argument) ?? 0) |
apps/ios/Snowbound/Sync.swift+6-2| ... | ... | @@ -3,7 +3,7 @@ import UIKit |
| 3 | 3 | /// A section's sync status from `sb_library_sync_status`. |
| 4 | 4 | struct SectionSync: Decodable { |
| 5 | 5 | enum State: UInt8, Decodable, Comparable { |
| 6 | case upToDate, syncing, inUse, notConnected, readOnly, failed | |
| 6 | case upToDate, syncing, inUse, notConnected, readOnly, protected, unreadable, failed | |
| 7 | 7 | |
| 8 | 8 | static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue } |
| 9 | 9 | } |
| ... | ... | @@ -55,6 +55,8 @@ enum Sync { |
| 55 | 55 | case .syncing: "Syncing…" |
| 56 | 56 | case .inUse: "Section in use" |
| 57 | 57 | case .notConnected: "Not connected" |
| 58 | case .protected: "Password protected" | |
| 59 | case .unreadable: "Can’t read this section" | |
| 58 | 60 | case .readOnly, .failed: "Unable to sync" |
| 59 | 61 | } |
| 60 | 62 | } |
| ... | ... | @@ -65,7 +67,8 @@ enum Sync { |
| 65 | 67 | case .upToDate: "checkmark.circle" |
| 66 | 68 | case .syncing, .inUse: "arrow.triangle.2.circlepath" |
| 67 | 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 | } |
| 71 | 74 | |
| ... | ... | @@ -77,6 +80,7 @@ enum Sync { |
| 77 | 80 | case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)." |
| 78 | 81 | case .inUse: "Someone else is saving this section. Sync continues when they finish." |
| 79 | 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 | 84 | 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 | 106 | grid point, outside any outline. It selects, drags on the grid, deletes and |
| 107 | 107 | moves with Insert Space as a picture does, and opens and saves as above. |
| 108 | 108 | |
| 109 | An outline holding only pictures or files takes a paragraph after them where a | |
| 110 | click beside them lands, as OneNote 2010 adds one when typing there. It is | |
| 111 | stored with the first edit that reaches it, and undoing back to it empty takes it | |
| 112 | out again (`corpus/object-outline`). | |
| 113 | ||
| 114 | A file printout's pages are pictures whose stored data is the printout's XPS | |
| 115 | package. The page draws the PNG OneNote rendered of each | |
| 116 | (WebPictureContainer14), framed in grey as OneNote frames them, and moves them as | |
| 117 | pictures. Snowbound does not write XPS, so a printout page deleted and brought | |
| 118 | back by undo returns as that picture (`corpus/printout`). | |
| 119 | ||
| 109 | 120 | ## Recordings |
| 110 | 121 | |
| 111 | Record Audio works as OneNote 2010's does. The caret's paragraph splits around | |
| 112 | a line saying when recording started, in OneNote's grey `cite` style, and the | |
| 113 | caret goes on below it. Text written while recording links to the moment it | |
| 114 | was written. On Stop, the file goes in above that line, named after the page. | |
| 115 | Hovering a linked note or a recording shows OneNote's blue play button in the | |
| 116 | margin, and a click asks the host to play from that moment. The host records a | |
| 117 | 16 kHz WAV and stores it as IMA ADPCM, which OneNote plays. | |
| 122 | Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph | |
| 123 | splits around a line saying when recording started, in OneNote's grey `cite` | |
| 124 | style, and the caret goes on below it. Text written while recording links to | |
| 125 | the moment it was written; text written while paused links to nothing, and the | |
| 126 | pause is left out of later moments. On Stop, the file goes in above that line, | |
| 127 | named after the page. Hovering a linked note or a recording shows OneNote's blue | |
| 128 | play button in the margin, and a click asks the host to play from five seconds | |
| 129 | before that moment, OneNote's default rewind. While a recording plays, See | |
| 130 | Playback highlights the note linked last at or before the moment playing, | |
| 131 | scrolling it into view when it changes. | |
| 132 | ||
| 133 | The host records audio as 16 kHz WAV and stores it as IMA ADPCM, and video as | |
| 134 | AVI of Motion JPEG at 320 by 240 and 15 pictures a second with PCM sound, both | |
| 135 | of which OneNote plays (`corpus/recording/video`). GStreamer writes that AVI | |
| 136 | directly; on macOS a capture session records a movie that `AVAssetReader` reads | |
| 137 | back into it off the main thread after Stop, the transport saying so and the page | |
| 138 | editable meanwhile. Mac OS X 10.6, without AVFoundation, opens recordings in the | |
| 139 | system's player and does not record. Snowbound plays both itself: the sound through the platform's | |
| 140 | player, decoded from IMA ADPCM first, and a video's pictures in the transport | |
| 141 | over the page, which also carries OneNote's Pause, Stop, ten-second and | |
| 142 | ten-minute skips, Seek To and See Playback. | |
| 118 | 143 | |
| 119 | 144 | ## Drawing |
| 120 | 145 | |
| ... | ... | @@ -133,6 +158,23 @@ click on ink picks it. The default pen draws in the open section's accent at |
| 133 | 158 | the light theme's shade, so a stroke stores one real colour and shows it in both |
| 134 | 159 | themes. |
| 135 | 160 | |
| 161 | A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as | |
| 162 | OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times | |
| 163 | 0.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 | ||
| 168 | Note tags on a table sit in the tag column centred on it, and a click checks its | |
| 169 | box (`corpus/table-tags`). A selection crossing a table's edge deletes as | |
| 170 | OneNote 2010 deletes one: nothing joins across the edge, cells inside are | |
| 171 | emptied, and where the selection runs on past the table its rows inside go, the | |
| 172 | table 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 | |
| 174 | Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs | |
| 175 | makes each paragraph's part an equation, and a deletion between two equations | |
| 176 | joins them where neither seam lies inside an object (`corpus/equation-join`). | |
| 177 | ||
| 136 | 178 | ## Content the editor doesn't understand |
| 137 | 179 | |
| 138 | 180 | Nothing 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 | 200 | character positions, so a screen reader's caret and selection land where the |
| 159 | 201 | eye does. Native selection and replacement actions go through the editor's |
| 160 | 202 | history like any other edit. The tree updates only while an assistive client |
| 161 | is listening, and never for a caret blink. The interface kit around the page | |
| 162 | doesn't have a tree yet. | |
| 203 | is listening, and never for a caret blink. The interface's own tree holds it at | |
| 204 | the page's box (see [the interface kit](ui.md#accessibility-and-the-keyboard)). | |
| 205 | ||
| 206 | ## Spelling | |
| 207 | ||
| 208 | Spelling is checked as OneNote 2010 checks it with its default proofing | |
| 209 | options. Words in capitals, words with digits, and Internet and file addresses | |
| 210 | go unchecked, and a word repeating the one before it is marked as repeated. | |
| 211 | Each run is checked in the language it stores, so a French paragraph takes the | |
| 212 | French 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 | |
| 214 | comes into view, and keeps the result by the paragraph's text, so drawing never | |
| 215 | waits on it. Marked words carry OneNote's red zigzag, one pixel thick at any | |
| 216 | zoom and laid on the device's pixel grid so a 1× screen shows OneNote's exact | |
| 217 | pixels, except the word still being typed. The context menu (the edit menu on | |
| 218 | iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the | |
| 219 | Spelling pane (F7) walks the page's marked words. A correction is an ordinary | |
| 220 | edit, one revision. Nothing about spelling is stored in the page. | |
| 163 | 221 | |
| 164 | 222 | ## Search, dates, conflicts |
| 165 | 223 |
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 | 169 | note to a moment in a recording. Ink and math were reverse-engineered, each |
| 170 | 170 | against an independent oracle. For ink, the stroke extents from OneNote's own |
| 171 | 171 | export, and for the Draw tab's pens, highlighters and shapes, the objects |
| 172 | OneNote stored while drawing them in the lab (`corpus/ink-tools`). For math, the MathML it exports, matched byte for byte. Recordings | |
| 172 | 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 | 174 | and their links follow what OneNote stored while recording in the lab, read |
| 174 | back through its XML export (`corpus/recording`). Embedded objects are read | |
| 175 | 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 | 178 | and kept, but never authored. |
| 176 | 179 | |
| 177 | 180 | Readers 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 | 22 | (`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else |
| 23 | 23 | in the crate is shared: library and settings, the sidebar, menus, page and |
| 24 | 24 | section management, templates, and screenshot and replay support. |
| 25 | Accessibility for the page goes through AccessKit's winit adapter on both. | |
| 25 | Accessibility goes through AccessKit's winit adapter on both: the interface's | |
| 26 | tree, with the page's grafted into it. | |
| 26 | 27 | `commands.rs` is the one table of commands: each one's title, its chords on |
| 27 | 28 | macOS and elsewhere, when it is enabled or checked, and what it does. The |
| 28 | 29 | 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 | 42 | sidebar take the system's desktop tint, appearance and focus state exactly. |
| 42 | 43 | - AppKit supplies the open and save panels, alerts, the date picker, date |
| 43 | 44 | 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 and | |
| 45 | selection colours. | |
| 45 | (used as the author, as OneNote uses Office's user name), the caret and | |
| 46 | selection colours, and the spell checker, `NSSpellChecker`, which the | |
| 47 | spelling thread calls on the main thread (10.6 has it too). | |
| 46 | 48 | - Frames present inside Core Animation's transaction, so a resize pairs each |
| 47 | 49 | frame with the window's new size instead of stretching the last one. |
| 48 | 50 | - A notebook on a mounted SMB share is opened through the embedded SMB client, |
| 49 | 51 | 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 | 55 | - The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are |
| 52 | 56 | the command table's, validated from the statuses each frame publishes, and |
| 53 | 57 | 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 | 79 | The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. |
| 76 | 80 | - zenity or kdialog provide the pickers and alerts. The XDG settings portal |
| 77 | 81 | 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 | 85 | - Wayland's clipboard goes through the window's own connection, since not every |
| 80 | 86 | compositor offers a clipboard to clients without a window. |
| 81 | - `crates/snowbound/linux` packages a tarball, plus an installer that adds the | |
| 82 | launcher entry the Wayland desktop needs to find the icon. | |
| 87 | - The executable carries its desktop entry and icon (`desktop_linux.rs`). The | |
| 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. | |
| 83 | 96 | |
| 84 | 97 | ### Windows and older macOS |
| 85 | 98 | |
| ... | ... | @@ -114,7 +127,8 @@ owns everything around the page: |
| 114 | 127 | No `ui` crate ships on iOS. Everything below the view is the desktop's code: |
| 115 | 128 | `notebook::session` with its replica and background publishing, the embedded |
| 116 | 129 | SMB client (credentials kept in the Keychain, as Files keeps its own), conflict |
| 117 | pages, search, and page management through the same ops. Notebooks from Files | |
| 130 | pages, search, spelling (through `UITextChecker`), and page management through | |
| 131 | the same ops. Notebooks from Files | |
| 118 | 132 | are read and written under `NSFileCoordinator`, so file providers see every |
| 119 | 133 | change. |
| 120 | 134 |
arc/sync.md+54| ... | ... | @@ -159,6 +159,13 @@ A notebook on a share that macOS has mounted is opened through the embedded |
| 159 | 159 | client with the account the system keeps for that mount. It falls back to the |
| 160 | 160 | mount only when it can't sign in that way. |
| 161 | 161 | |
| 162 | A notebook can also be opened from its server's address, with no mount at all (File ▸ | |
| 163 | Open Notebook from Server…), which is how Mac OS X 10.6 reaches a server that no longer | |
| 164 | speaks 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 | |
| 166 | the keychain (the Secret Service on Linux) when the user asks to remember it, and | |
| 167 | otherwise the sign-in asks again at the next launch. | |
| 168 | ||
| 162 | 169 | A file's identity is its root object space, not its server file ID, which |
| 163 | 170 | changes when maintenance replaces the file. A check opens by path each time |
| 164 | 171 | for the same reason. |
| ... | ... | @@ -228,6 +235,46 @@ copies and learns of other apps' writes through file coordination (`NSFilePresen |
| 228 | 235 | file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15 |
| 229 | 236 | seconds. Closing a notebook deletes its copies, except any with edits still waiting. |
| 230 | 237 | |
| 238 | ## iCloud Drive | |
| 239 | ||
| 240 | A cloud drive syncs whole files with no lock and no compare-and-swap. The stamp check still runs | |
| 241 | before each append, but only against this device's copy; when two devices append to the same | |
| 242 | base, 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 | |
| 244 | fact, and each sync step merges the versions it finds before anything else: | |
| 245 | ||
| 246 | ```text | |
| 247 | for 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 | ||
| 257 | Each revision the merge writes is named after the version's revision it merged (a hash of it), | |
| 258 | so "holds merged" is a lookup by revision identity. A version merged once, by this device or | |
| 259 | another, merges as nothing the next time; two devices merging one version at once write the | |
| 260 | same names, and the merge of their two results finds everything held. Any device merges any | |
| 261 | version at once; nothing waits for the device that wrote it. A version of another section, or | |
| 262 | one that cannot be read, is kept beside the file as `Name (Device).one` instead. | |
| 263 | ||
| 264 | On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the | |
| 265 | iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is | |
| 266 | asked for and read later; the replica serves it meanwhile. Each publication is an upload and a | |
| 267 | chance 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, | |
| 269 | the Mac's window loses focus or the app quits. A folder presenter reports other devices' | |
| 270 | changes and conflict versions, which change no file, and every section is checked every 15 | |
| 271 | seconds 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 | |
| 274 | compare-and-swap, either side of a race winning, late deliveries, offline spans) and checks that | |
| 275 | every device ends on the same file with each typed string once. OneNote 2010 cold-opens a | |
| 276 | merged section with its conflict page as its own (`corpus/icloud-merge`). | |
| 277 | ||
| 231 | 278 | ## Notebook structure |
| 232 | 279 | |
| 233 | 280 | Sections, 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 | 284 | treats the file as a stranger and re-identifies it. Deleting sends sections and |
| 238 | 285 | pages to `OneNote_RecycleBin`, as OneNote does. |
| 239 | 286 | |
| 287 | What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the | |
| 288 | Windows hidden attribute so that OneNote never makes a section group of it. Its files are | |
| 289 | plain files, not revision stores: pictures named by their content, and small JSON | |
| 290 | mappings that every writer merges before replacing. The first is tag art: a tag keeps | |
| 291 | OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and | |
| 292 | symbol to a picture that Snowbound draws in the symbol's place. | |
| 293 | ||
| 240 | 294 | ## Recovery and migration |
| 241 | 295 | |
| 242 | 296 | `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 | 25 | it is, and it settles fast. Popups open and close on short timed curves, |
| 26 | 26 | slow enough to follow: a menu swings out of the pointer or its button as it |
| 27 | 27 | fades in, a combo's field widens into its list, and a dialog swings up into |
| 28 | place over a dimmed window, as Windows opens a window; the command palette swings | |
| 29 | in the same way near the window's top. On GNOME and KDE, menus instead | |
| 28 | place near the window's top over a dimmed window, as Windows opens a window; the | |
| 29 | command palette swings in the same way, undimmed. A filtered list shows its new | |
| 30 | results at once. On GNOME and KDE, menus instead | |
| 30 | 31 | look and move as the desktop's own (see [platforms](platforms.md)). |
| 31 | 32 | |
| 32 | 33 | ## Why not native widgets |
| ... | ... | @@ -85,7 +86,7 @@ frame N |
| 85 | 86 | are built each frame and the solver picks, so a group folds in the frame the |
| 86 | 87 | window narrows, with no widths remembered or worked out by the app. A folded |
| 87 | 88 | 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. The | |
| 89 | opens beside its row as a submenu, on hover, Right or a click. The | |
| 89 | 90 | section tabs scroll sideways past the row's room, fading out where cut. |
| 90 | 91 | - **Nothing runs while nothing changes.** The kit reports whether it wants |
| 91 | 92 | 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 | 107 | The kit doesn't know what a notebook is. `snowbound` assembles the window from |
| 107 | 108 | these parts. |
| 108 | 109 | |
| 110 | ## Accessibility and the keyboard | |
| 111 | ||
| 112 | The interface presents itself to assistive technology as an AccessKit tree | |
| 113 | built from the same boxes as the frame. A box with a `Spec::role` is a node; | |
| 114 | the kit's widgets choose their own, and `Ui::access` adds states, values and | |
| 115 | names to any box. A box without a role lets its children through, and its | |
| 116 | text 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 | ||
| 133 | The page keeps its own tree (see [canvas](canvas.md#accessibility)). The | |
| 134 | host grafts it at the page's box, sending the interface's tree first, since | |
| 135 | it holds the graft, then the page's. | |
| 136 | ||
| 137 | ```text | |
| 138 | Window | |
| 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 | ||
| 146 | The keyboard reaches the same controls. Tab steps through them, within an | |
| 147 | open popup or across the window; a toolbar, tab list or tree is one step, | |
| 148 | entered at its selected control, and arrows move within it. F6 steps between | |
| 149 | those groups and the page, as Windows and GTK step between panes, and on | |
| 150 | macOS Control-F5 goes to the toolbar. Space or Enter presses, Escape closes a | |
| 151 | popup or returns the focus to where the keyboard took it from, and the | |
| 152 | focused control wears a ring until the pointer is used. The page keeps Tab | |
| 153 | for itself, so F6 leaves it. | |
| 154 | ||
| 109 | 155 | ## `draw`: the renderer |
| 110 | 156 | |
| 111 | 157 | `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 | 188 | |
| 143 | 189 | A covered window gets no redraws, so interaction is scripted. |
| 144 | 190 | `SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot |
| 145 | steps. `--screenshot` draws the whole window offscreen in each appearance | |
| 191 | steps, and `accessibility` steps that write the whole window's tree as text, so a | |
| 192 | hidden window's tree can be checked without a screen reader. `--screenshot` draws the whole window offscreen in each appearance | |
| 146 | 193 | (the README's screenshots are made this way). `ui`'s own tests build frames |
| 147 | 194 | headlessly 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 | |
| 7 | that notebook: a selection from "Before" into the first cell deleted, one from the | |
| 8 | second cell out to "After" deleted and "X" typed, one from "Before" over the first row | |
| 9 | into "Beta one" typed over with "Y", and one from "Before" over the whole table deleted. | |
| 10 | OneNote joins nothing across the table's edge, empties cells inside the selection, and | |
| 11 | where the selection runs on past the table removes its rows inside, and the table when | |
| 12 | all 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 | |
| 16 | published as its own revision; `cold/` is a fresh OneNote 2010 read of it, whose pages | |
| 17 | read as `keys/` does. `tools/test_cross_container.py` checks this without a VM. | |
| 18 | Regenerate with `SNOWBOUND_CROSS_CONTAINER_EXPORT` set to a new absolute directory while | |
| 19 | running 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/cross-container/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | Click(160, 236) | |
| 9 | Sleep(500) | |
| 10 | Send("{Home}{Right 6}") | |
| 11 | Sleep(300) | |
| 12 | Send("+{Right 49}") | |
| 13 | Sleep(500) | |
| 14 | Send("{Delete}") | |
| 15 | Sleep(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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | Click(160, 236) | |
| 9 | Sleep(500) | |
| 10 | Send("{Home}{Right 6}") | |
| 11 | Sleep(300) | |
| 12 | Send("+{Right 11}") | |
| 13 | Sleep(500) | |
| 14 | Send("{Delete}") | |
| 15 | Sleep(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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | Click(250, 260) | |
| 9 | Sleep(500) | |
| 10 | Send("{Home}{Right 5}") | |
| 11 | Sleep(300) | |
| 12 | Send("+{Right 28}") | |
| 13 | Sleep(500) | |
| 14 | Send("{Delete}") | |
| 15 | Sleep(500) | |
| 16 | SendText("X") | |
| 17 | Sleep(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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/cross-container/keys/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | Click(160, 236) | |
| 9 | Sleep(500) | |
| 10 | Send("{Home}{Right 6}") | |
| 11 | Sleep(300) | |
| 12 | Send("+{Right 30}") | |
| 13 | Sleep(500) | |
| 14 | SendText("Y") | |
| 15 | Sleep(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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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' | |
| 7 | New-Item -ItemType Directory -Path $inputs | Out-Null | |
| 8 | $app = New-Object -ComObject OneNote.Application | |
| 9 | $notebookId = '' | |
| 10 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/cross-container/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | |
| 5 | chosen for it, OneNote's Yellow Star, 13), "Ship it" (Snowbound's Rocket, OneNote's Plane, | |
| 6 | 127), "Done" (the Rocket over a Blue Check Box, 3, checked) and OneNote's own "Important". | |
| 7 | The page stores OneNote definitions only; `.snowbound/tags.json` maps each tag's name and | |
| 8 | symbol 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 | |
| 11 | gives `.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 | |
| 13 | no `.snowbound` section group (`before-read/page-000.png`). OneNote then edits the page | |
| 14 | through COM (`scripts/custom-art-edit.ps1`: new text on "Launch day", "Done" unchecked) | |
| 15 | and syncs; `read/` is the page after, `edited.png` its render, and `sidecar.json` what | |
| 16 | OneNote 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 | |
| 18 | a VM. Regenerate with `SNOWBOUND_CUSTOM_ART_EXPORT` set to a new absolute directory while | |
| 19 | running 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/custom-art/cold/scripts/custom-art-edit.ps1 created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root) | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $notebook = Join-Path $Root 'notebook' | |
| 5 | $app = New-Object -ComObject OneNote.Application | |
| 6 | try { | |
| 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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | |
| 5 | paragraphs of their own, after a selection from "aa+|" into "cc|" was deleted: | |
| 6 | OneNote joins them into one equation, `aa++dd`. `equation-per-paragraph.xml` is | |
| 7 | Alt+= over a selection across two paragraphs: each paragraph's part becomes an | |
| 8 | equation 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`: | |
| 12 | the same equations typed and the same selection deleted through the editor, whose | |
| 13 | joined equation reads back as one. `cold/` is a fresh OneNote 2010 read of it, whose | |
| 14 | MathML is OneNote's own. `tools/test_equation_join.py` checks this without a VM. | |
| 15 | Regenerate with `CANVAS_EQUATION_JOIN_EXPORT` set to a new absolute directory while | |
| 16 | running 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 | |
| 3 | href="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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/equation-join/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | |
| 3 | style='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 | |
| 4 | style='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 | |
| 5 | iCloud Drive. The Mac types "Mac: " at the start of the first paragraph; the iPhone, offline, | |
| 6 | types "iPhone: " at the same place, appends to the positioned outline and adds a page "From the | |
| 7 | iPhone" with a body. The iPhone's upload becomes a conflict version, which the Mac merges: the | |
| 8 | appended 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 | |
| 12 | has 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 @@ |
| 1 | Fictitious 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 | |
| 3 | style='font-weight:bold;font-family:Calibri' lang=en-US>Mac: Fictitious: café, </span><span | |
| 4 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 6 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 7 | lang=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 | |
| 8 | URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG | |
| 9 | IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB | |
| 10 | mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ | |
| 11 | QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY | |
| 12 | BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn | |
| 13 | NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE | |
| 14 | BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ | |
| 15 | wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW | |
| 16 | ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ | |
| 17 | GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU | |
| 18 | RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 | |
| 19 | QQrSFUA=</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 | |
| 20 | v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF | |
| 21 | CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg | |
| 22 | KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z | |
| 23 | mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ | |
| 24 | qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG | |
| 25 | AEAKABEgLMlfQdkfI0A=</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 | |
| 26 | AAAASUVORK5CYII=</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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/icloud-merge/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | ||
| 3 | OneNote 2010 draws a pen's pressure but stores only what the pen reports. With a mouse, or | |
| 4 | with 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 | |
| 6 | attributes carry IgnorePressure (`0x08003411`) and its dimension table holds X and Y. With the | |
| 7 | option on, the attributes leave IgnorePressure out, and a pen that reports pressure adds it. | |
| 8 | The lab's Windows 7 has no digitizer, so the pressure strokes here come from ISF: WPF builds | |
| 9 | strokes 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 | ||
| 17 | What 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 | ||
| 29 | Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen | |
| 30 | that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure | |
| 31 | from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a | |
| 32 | finger'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 | |
| 36 | no-pressure stroke erased, four Snowbound drawings of a 500 HIMETRIC pen below it (none, half | |
| 37 | and full pressure, and a ramp), and on "Strokes" the ramp moved to (72, 108) and the wave | |
| 38 | deleted. `cold` is a fresh OneNote 2010 read: every width as drawn above, in its PDF export | |
| 39 | and `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 | |
| 4 | DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a | |
| 5 | U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ | |
| 6 | gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A | |
| 7 | ORQAgv4Di/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 | |
| 9 | QAoYBYJ/BgyUgv4jQ/iNEIL9yfuUAAMVRgBAChkFgn8GDJSC/krz+SvQgv4Dk/gOUAMVRgBAChkF | |
| 10 | gn8GDJSC/nKj+cqQgv4Hk/geUAMVRgBAChkFgn8GDJSC/ppT+mlQgv4Lk/guUAMVRgBAChkFgn8G | |
| 11 | DJSC/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 | |
| 14 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq | |
| 15 | A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApUA4L+I0P4jQCC | |
| 16 | /wCcaf4BONSCAAUURgBAAAAJBAUURgAAAAASABEguqhoBLnfiUiQN9dTlXSFBAoAESAAPBmUtFDd | |
| 17 | AQMVRgBACgARIDabTT+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 | |
| 19 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq | |
| 20 | A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC | |
| 21 | /wCwQf4BYISC/guT+C5QBRRGAIAAAAkEBRRGAAAAABIAESC+dPnOv2otSZfb454We3N4CgARIAA8 | |
| 22 | GZS0UN0BAxVGAEAKABEgNptNP7f0c0E=</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 | |
| 23 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq | |
| 24 | A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC | |
| 25 | /wDEGf4BiDSC/huL+G4wBRRGAMAAAAkEBRRGAAAAABIAESBAFhfWgEA7TLxtxXvme3CCCgARIAA8 | |
| 26 | GZS0UN0BAxVGAEAKABEgNptNP1z6gUE=</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 | |
| 27 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq | |
| 28 | A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApzKYL+I0P4cpsW | |
| 29 | LFixYsWLFixYsIL/AN6R/gG9JAAAAAAAgn4YsssWWWLLFlliyyxZZYAFFEYBAAAACQQFFEYAAAAA | |
| 30 | EgARIDlXT31MN1lIqRuN46Oag5AKABEgADwZlLRQ3QEDFUYAQAoAESA2m00/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 | |
| 3 | LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+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 | |
| 4 | LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+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 | |
| 5 | 0YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC | |
| 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/ | |
| 7 | BgyUEIIAAIJ+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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/ink-pressure/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/ink-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 | |
| 3 | LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+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 | |
| 4 | KYJ/ARlixYsWLFixYsWLFgCHaEh8fls1ntBoM5mcni8JgkYkUunk8oU6nUqkEQgUPjcrm87n9DnM | |
| 5 | 1lMZhcEiQIfifuG1gsRisbkcjlMnk8lj8bicNgsAhESikgkEklEmk0gkEWiUIgUDhsRjsdkcpkoD | |
| 6 | FUYAQA==</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 | |
| 7 | LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+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 | |
| 8 | 0YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC | |
| 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/ | |
| 10 | BgyUEIIAAIJ+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 | |
| 3 | QAoPBYJ/BgyUggCCAAMVRgBAChgFgn8GDJSC/iND+I0Qgv3J+5QAAxVGAEAKGQWCfwYMlIL+SvP5 | |
| 4 | K9CC/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 | |
| 9 | DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a | |
| 10 | U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ | |
| 11 | gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A | |
| 12 | ORQAgv4Di/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 @@ |
| 1 | param([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. | |
| 4 | Set-StrictMode -Version Latest | |
| 5 | $ErrorActionPreference = 'Stop' | |
| 6 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | |
| 7 | Add-Type -AssemblyName PresentationCore, WindowsBase | |
| 8 | $P = [System.Windows.Input.StylusPointProperties] | |
| 9 | $U = [System.Windows.Input.StylusPointPropertyUnit] | |
| 10 | ||
| 11 | function 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). | |
| 17 | function 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 | ||
| 39 | function 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 | ||
| 47 | function 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)) | |
| 57 | for ($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 = @() | |
| 64 | for ($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 | } | |
| 70 | for ($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 = '' | |
| 86 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/ink-pressure/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/ink-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 | |
| 7 | outline in OneNote 2010 and typing "abc": OneNote adds a paragraph after the object | |
| 8 | holding the text, and undoing removes that paragraph again. | |
| 9 | ||
| 10 | `candidate/` is the output of `typing_beside_an_outlines_only_object_adds_a_paragraph` | |
| 11 | in `crates/canvas/tests/object_outlines.rs`: the editor shows each outline with a | |
| 12 | paragraph after its object that is stored only once typed in, and the typing, its undo | |
| 13 | and its redo are published as revisions; `cold/` is a fresh OneNote 2010 read of it, | |
| 14 | whose pages read as `keys/` does. `tools/test_object_outline.py` checks this without a | |
| 15 | VM. Regenerate with `SNOWBOUND_OBJECT_OUTLINE_EXPORT` set to a new absolute directory | |
| 16 | while 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 @@ |
| 1 | Plain 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 | |
| 3 | oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h | |
| 4 | iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO | |
| 5 | 4iRO4lzdwK/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/object-outline/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | Plain 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 | |
| 3 | oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h | |
| 4 | iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO | |
| 5 | 4iRO4lzdwK/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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | MouseMove(190, 260) | |
| 9 | Sleep(800) | |
| 10 | Click(236, 301) | |
| 11 | Sleep(500) | |
| 12 | SendText("abc") | |
| 13 | Sleep(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 @@ |
| 1 | Plain 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 @@ |
| 1 | ComObject("OneNote.Application").NavigateTo("{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}", "", false) | |
| 2 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 3 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 4 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 5 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 6 | Sleep(3000) | |
| 7 | CoordMode("Mouse", "Screen") | |
| 8 | MouseMove(190, 260) | |
| 9 | Sleep(800) | |
| 10 | Click(238, 282) | |
| 11 | Sleep(500) | |
| 12 | SendText("abc") | |
| 13 | Sleep(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 @@ |
| 1 | Plain 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 | |
| 3 | oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h | |
| 4 | iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO | |
| 5 | 4iRO4lzdwK/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/object-outline/keys/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | Plain 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 @@ |
| 1 | Plain 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 | |
| 3 | oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h | |
| 4 | iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO | |
| 5 | 4iRO4lzdwK/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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' | |
| 7 | New-Item -ItemType Directory -Path $inputs | Out-Null | |
| 8 | $app = New-Object -ComObject OneNote.Application | |
| 9 | $notebookId = '' | |
| 10 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/object-outline/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | |
| 4 | two-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 | |
| 6 | printout's XPS package, shared by its pages, with IsPrintout, the page shown | |
| 7 | (DisplayedPageNumber) and OneNote's PNG rendering of that page as its | |
| 8 | WebPictureContainer14 (MS-ONE 2.3.98), which Snowbound draws, framed in grey as OneNote | |
| 9 | frames printout pages. | |
| 10 | ||
| 11 | `candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures` | |
| 12 | in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted | |
| 13 | and the deletion undone, and text typed before the citation, published as one revision. | |
| 14 | Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote | |
| 15 | showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout, | |
| 16 | the restored one a picture. `tools/test_printout.py` checks this without a VM. | |
| 17 | Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while | |
| 18 | running 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 | |
| 3 | XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk | |
| 4 | otNUTao2FcavTMWPRFKzpATUYFCQhF0nriBgVJQUtOlvOY/ry+6y+/btvt33du+ObLK79+Pc83vn | |
| 5 | 3HPPvfecB7///vsHguU1MHi3+ZIVozFbe3pu3Mb/DA7ea26/InF8kePGJBviUTgjKTE8bLQxcXxs | |
| 6 | dITEuuov9qBGke67899WcxdQtFh7rN03zd/09Ny8owS7I8eGp0xOAOQG/fjMVIN24dcM0tbuG40t | |
| 7 | ly3WXqDb2tHZ1z+gBK5S2gwbPXpqsj7FkGDQx0D6k40TpNQKeBlVI2229ja2WJouXG780iJFZB8a | |
| 8 | 6E++btPeGdfMYf+7q++7jn/SWdwXPqZ1fCLKn48zDIwKs0bEWCNj3FaH0KcnJ0LcgXpm2iS35QNV | |
| 9 | QHVIY3499Xmb6YuOxi/NrgU3s/Mr440u482u5F4r3mP7bynExMaEpJ6xkeboCU0JSeao+J6xD7no | |
| 10 | CGBnT304PyvZqB+vED3ymlUL0lDOAPhY/cXmS84NqPB7dzM729O7LSm9V4w3rwFjeQP2vlZf+NjW | |
| 11 | mMTmeMOlmMTGhCkjAY95vTAntTAnTSWQBxhp6OdaU0ut6QImYEcMoHszui3p3ebMq18lX5dqQnuP | |
| 12 | pUctQMND6JsSpuDdqbbXx0bnz0ounj89sJAHDGkAfPiT5vpzHY5szb98PvfyeUiwR7OsR/AoVBjy | |
| 13 | DSmvm5Tx8aT0gVGj7XrJ+GliccF0CDqWcAoR4KJZfyONabj6xJma42ccLSwCGO+RA/3+Z4RvewTM | |
| 14 | psRHTk1Kx79b4WPFjcOCK8z5WdkTWX4Wcf8h3Wbu2nW4Horajqc5Vy4+1nE2OAB2+rhAqx9Jyqqd | |
| 15 | PMNOyiHiryz6RV5Wsm8fspFa8wfSwHh7zWkYXGIiYCovam8objNpTkXLAwZ2XO3k6XtT58GGF7cA | |
| 16 | r9ySkrl+wFtZpJ1iDCEuam8o7Dgrj2VarwWtfiRpFlAXDwRrs/LiuYoux5VCGkb11n2n7OQYKnpZ | |
| 17 | 04cBXCCp5ymB7bY7vWB/So5YpQPpZc/lQasrQafvkcY2w85D9TsPfjZ49y6jGBiXn61V7UpJCc5K | |
| 18 | adMp3iUFM5aX5UWO+5GUFqSX8THSpnNfV/211tpzg2MsHQNHvGGfLy/LL5k/Q3ojbkv6DGk4uTa8 | |
| 19 | e1S8PobTY+V/DuDdLRG8ADgAvN/KLsVCnHEDanzliwW+Uua+QRpL5I27TzJ1jZ0GzMclrSYOoacc | |
| 20 | gL22adbTYvt88VPZy57P87Qdx/LeIo194qp3j4pXyVg7VTQcDALvh/fMldcCbLQ9abk7phUyYw1i | |
| 21 | /cbyp/Rx0fIapFpeIY1F1NqtB5nLGkb12tP7uLr2Bg9WFy70quxSiDh9g5l79asLsV8iu3H5SNtp | |
| 22 | bIjy6vr92HGSTQqv6MiBnRkF22Y+yb5flJu++tcL5bnNZSK9/p0jh+vOEwWYlVfV7w9ZT4jSD2hz | |
| 23 | nGFd7ktslww+ta1/+KWMNZjHSGO5vHpzNbOxoavfqHsvRDyaSoM6UvvwpK6f8zwzy7HzvWnVs55O | |
| 24 | 254hDfur8i8fsMMCsK6hsQM1/lDrd2/avE2znqFRx0aN27jqOY+OsHmANFbMFW99wOyv8nO1S87W | |
| 25 | hhq7AzteeMvXzykjmxw22oaKUumucqlIA+YVf9zLnF+wsWGCBXbYodk7rPF1uS/SnjdOqVZVFOdM | |
| 26 | e1gKKyQhjeMDS19/j2CGdf1m3Xt5lwVzTEofvIxvOdAWk1i5oJzOr0GyYaBJUePukcbcvPxPf2+z | |
| 27 | XCOYN3z0N2w7+pZ03pqnHIApvqLwd2SQA+ztr7/i9gSLznUfsLTXbK7mMHuKhNLlsdjZeGIH1rfo | |
| 28 | CGelK6s+wPTqulM3SGNBhZsT1ATMbC7NSkMovX14JLccfZvAxsQKKwra10V1V0hvr/43WzcDZm6C | |
| 29 | SYfBPyWx3/9G3fvklwTYb77zoRyksWjGaQKqubj5JN+Y8g94nvYCLQvLiWrhhM/huuaRWnAu05ie | |
| 30 | 1205RLuQ8IJhC9JTCnh5v3EAYL/Q8gl1twHHQEaYsJ0jjY1IWlNhGoCz029E847kcWBZ05GUoTsu | |
| 31 | EM51Ww85bcQJ0tAAbPdiZcNB7tOWx31/1sJUzSZsTLswsBx7t0caenvb3o+pHEwwboX5EzBv+oIp | |
| 32 | vrzpCLUAA4tCQohf9khj15nu0dAJIW/65nX9zIGylk+YDt+x/7QrpCHQuw7WUwlsYCh3I9nPLAid | |
| 33 | 7laZhK3F6pNncCJoRJnetq+OBBoY82WVFp8PLJRwtJ4oxw0p50hDs//z+Bn6jZ8T0iLMgjIe3kq2 | |
| 34 | hZY49zUbyP15GiY3LaCh6/lWlXaRhuOMiXX1yfuX3+4jXXNc+Pbl5pPaHSen3GZjDYv1p2e/Zka4 | |
| 35 | gDS2MZirhD0RnGsa5QDEms5iQ0kfM7XQKASka04KM/Si9s/5SV6NAiwmG/eW6eP+E4KqtiENAf+o | |
| 36 | 4RL9wEoEwWhDeQhweZHE2mL1DYWDsiGN6G7MFuMXX4Pj+QDMzL95vN52RsiG9JftV2l4CCgTHOPk | |
| 37 | owAH2LGRVkungDSLlptxjd+ADZ6HhKnnL9qs8H7q8HexQ3Cb8ctzwYPzAw+wsKmYms1XenX4Y5M0 | |
| 38 | vwobTEhjLLThYTPFLlh0+KMPiNgYZOPkw5nZ2U5MaLN06W4NHyiceNtJ5E7OLE1zAFGSif7uG7d1 | |
| 39 | vd8Koe4jB77T9Kg48Y4coDPCeN2+M6Dr6xcA5rvRwfesRA4KSPdcv61jHnBujgUf0uwM4K07/bru | |
| 40 | b4UDR7HfKRXzPvg4qKERsRs9usGBexqim5PqKQfYfpVOPyGKKveF/SAMtact8vLq5ABdvsVNa11Y | |
| 41 | 2Cgi0S7guDrp5lR5xAEESKHycdEROvzRBykZgzzqhhcOOAduhY8hGiIjwnWx0ZGC9h7+NuD0cQJ8 | |
| 42 | xQFrhJDJ6aGIMfe199Xhb33VDW8n4BxArjeiQR8XpUsxCMkCKP0bfwUTB5DMj4Zj0Mfq0ocjxCOW | |
| 43 | nWPin2AadgiOpTneSKNGJkbM0xFI3YUPgNkcpY1knCGImYwhA1BIL1VMMU6wnS7KSNbT58aJU2S0 | |
| 44 | yKuokwPNcUZS0gg1ijijNqTTkwSkkaFTnURzqmRwgMktBBrVbUjnTBNE+dPER9haW0bTvIqqOHDM | |
| 45 | KCRqmplq0+E2pBG0DAJOU7VdHidVkc6Jkc4BJN6jFA9DyRVTBaTxH5bGBUm7pDfHS6qWA8ivSLQh | |
| 46 | hyZFfhdu6yDsP5zg+AxrzS4dn2oHwwkbiQNDmRVn0K8l8wUdLiAN22zhbCHlQ01yDmeipjlwOCmL | |
| 47 | Wd0seOz9W7VF86bS8JCCj4u1dpEGxrvSC4j+RbkZbCD3kUZM8Oxpk/EDioqzfGh3zKFJ+baZRbQn | |
| 48 | DYeYOIveD2IXrXgun7iDlA8w3kKTU5oeNTCGSqYhVLxcIM7C8wOkodORPpPKbf7505oec2gSv3nW | |
| 49 | M2yGtstpbR+PrLx0DhnhrTGJSPERmvzS6Kihhpk75LWlRXajsEcaGx5LSudQoa0zi7hpphXUKdMS | |
| 50 | UQtXiWO+BidxQ194PIvyo0IPrMlfzLcyNQE28p7S+TAkWFr50nxHmp0gjWl87dInSYdDpmHLaWKo | |
| 51 | oUwklPbhYefm2t8UQTFLQhqF4Alf82ohld6TNu+UKClyKDNUnWOnxNVEGwzqkZIqOZFpqoNFN3nG | |
| 52 | 8Xot9yVk7lHnOEOcKkzPv19QTie4kQURK6uRGDIi0qiAtKjsOAoSNPFjwmp7qmBCAReskkAYZtvX | |
| 53 | lj7pIo2tK6ThDN+4+llse6EhqIjK+eV891o9YNvs5Ud/xc4PIRke2dFyZJombGTKZNYZniBuiqsE | |
| 54 | bCS4ZHnIYUG7zXnoSqZpSHhS8LywPc3KBUu4ZAcWbAgbls7MSbLs+TzxToZMmaZqeF6YKd6YMGXJ | |
| 55 | Eyv4nB0osCFmUNpsTfXC47MWP5UthRj3eS1ZKwgLvX449xYCKGw8voMHJJTCYh+WAcwrFv6WTDC8 | |
| 56 | sKZaPbwYdtuLB0ijLQQHXzucR4tnM3XLXN8WEGctRcvlJXOWlMyV3oVnSKNdhButfOsfyJpJffB8 | |
| 57 | 49J57U1JcdpptAMTTMrcLO7RY6RRGak81myqYVnHMzu/QvY0HhDHGyBd1B06GFIETyWV8Si7uLdI | |
| 58 | oz5S6iHXGoUTxmsoVd77PJOtz8HGvgM2mdiOInYv/lxR4nrdPBINcmSatbVt36mdh4SsS/gSiU6h | |
| 59 | zHkgeF/hvTOjYMe0QubAwMkCeMHgzpLXvldIo0ukdVi35QDlXsILmdgqGg5y4ZYHBquFXYaqnFLm | |
| 60 | /4LGrny5QHwoTEb73iKNLpHgeu3WAyxTNb4p7Di7suEAn7ll4IF11K70RyHNrC72LaoqSuCslNGa | |
| 61 | D+Zpx16x2t60+zizyTFzQ5Mj/56X9IVUdRjYcH7RyU56wSuC1ZSLfQvp/PGBTLPOEK5w276PWZ5b | |
| 62 | fI+ow8jow7NvucUDR8B2TC+E/5GVxKFs7CV6L8qsQV8iTY1i5t7w7lEk+mB9AG/k4IJKdzvgECzg | |
| 63 | iDEM7GVl+Z4ul92yzvdIU5ewybfvP00x4umFGJbQ5zzNMWOII8b4CQ7O5WV5sg1sF3grhTS6hDLf | |
| 64 | fegz5Mq0w7u4zVTaagrlgLTYhqpJmS3W1WDXotz08pK5+jhbJBIlXgoiTeQ6xRtr7sKOM8WtppDK | |
| 65 | /AHHNW43YhtKbHP5AWMCQnGkXeCNn4A08AbqQexvgesDt59wMZ0dHGAiq7Qci3WDn5CmLrHyrjW1 | |
| 66 | 7PnX52J7Dd8D5tlXLiJbF1JqBo1WJ4DrJmXg3e6gDk7nFS+YDpvL6XFdJVS3/2Tajno4zJFJ8+in | |
| 67 | F8VTOJWBfw2rMvzTqOPFBcAYHW6zlhbMsLsxpRC0ds36Vabt+iYRrz5xps1yzXG0cKxilwzZYfCu | |
| 68 | ctSHIn8ZESsIwZ/s7CwaF4S4cHZq8fzpyhlcbh+XQCLNiMPOGM44nGq8hLW4U4oZ6pjXWcYBt2NT | |
| 69 | tADclvBLNyVMaZyYxBzUdj3CkYkz84gs4kMHiOxBqQJpRj0MdUB+rP7CSJDTpA68gb3xZheyBMEt | |
| 70 | 44epnaTWHB1viZqAAKttMXoX4dCxq/hY9iNQ0QGUYMcHQl1IM/qQgxFgI+Fm04XLLlCn8tDtxps2 | |
| 71 | /Z/SeyVisN/2KAwl6MSj4KnaxyYSIOwZG0lbwhSKzxwVb7cucuQjwnxlpRlnpv4kM82ghN9Dtiiz | |
| 72 | iipF2m5gABu5+oB6a0cn20TxfvBetgDXdIohQc3oigeoDaTFFMOOazV3tVk6r3bfQr5dv2EPnYwo | |
| 73 | 2Zh6M1MN+vgoVWlmKY+s9pB2HBVUffMlW1o/s7UHM/3g4D3Ks2z+pocdkZDCC2z4Tx0KlmucGDv+ | |
| 74 | x+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 | |
| 3 | href="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 | |
| 4 | xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr | |
| 5 | oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 6 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 7 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 8 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 9 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 10 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 11 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 12 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 13 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 14 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 15 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 16 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 17 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 18 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 19 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 20 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 21 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 22 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 23 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 24 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 25 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 26 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 27 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 28 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 29 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 30 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 31 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 32 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 33 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 34 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 35 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 36 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 37 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | |
| 38 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | |
| 39 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | |
| 40 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | |
| 41 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | |
| 42 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | |
| 43 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | |
| 44 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | |
| 45 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | |
| 46 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | |
| 47 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | |
| 48 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | |
| 49 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | |
| 50 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | |
| 51 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | |
| 52 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | |
| 53 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | |
| 54 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | |
| 55 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | |
| 56 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | |
| 57 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | |
| 58 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | |
| 59 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | |
| 60 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | |
| 61 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | |
| 62 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | |
| 63 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | |
| 64 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | |
| 65 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | |
| 66 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | |
| 67 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | |
| 68 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | |
| 69 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | |
| 70 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | |
| 71 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | |
| 72 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | |
| 73 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | |
| 74 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | |
| 75 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | |
| 76 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | |
| 77 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | |
| 78 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | |
| 79 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | |
| 80 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | |
| 81 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | |
| 82 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | |
| 83 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | |
| 84 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | |
| 85 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | |
| 86 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | |
| 87 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | |
| 88 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | |
| 89 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | |
| 90 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | |
| 91 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | |
| 92 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | |
| 93 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | |
| 94 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | |
| 95 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | |
| 96 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | |
| 97 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | |
| 98 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | |
| 99 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | |
| 100 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | |
| 101 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | |
| 102 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | |
| 103 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | |
| 104 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | |
| 105 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | |
| 106 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | |
| 107 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | |
| 108 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 109 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 110 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 111 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 112 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 113 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 114 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 115 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 116 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 117 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 118 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 119 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 120 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 121 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 122 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 123 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 124 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 125 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 126 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 127 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 128 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 129 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 130 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 131 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 132 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 133 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 134 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 135 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 136 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 137 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 138 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 139 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 140 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | |
| 141 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | |
| 142 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | |
| 143 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | |
| 144 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | |
| 145 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | |
| 146 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | |
| 147 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | |
| 148 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | |
| 149 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | |
| 150 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | |
| 151 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | |
| 152 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | |
| 153 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | |
| 154 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | |
| 155 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | |
| 156 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | |
| 157 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | |
| 158 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | |
| 159 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | |
| 160 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | |
| 161 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | |
| 162 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | |
| 163 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | |
| 164 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | |
| 165 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | |
| 166 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | |
| 167 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | |
| 168 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | |
| 169 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | |
| 170 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | |
| 171 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | |
| 172 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | |
| 173 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | |
| 174 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | |
| 175 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | |
| 176 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | |
| 177 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | |
| 178 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | |
| 179 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | |
| 180 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | |
| 181 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | |
| 182 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | |
| 183 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | |
| 184 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | |
| 185 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | |
| 186 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | |
| 187 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | |
| 188 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | |
| 189 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | |
| 190 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | |
| 191 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | |
| 192 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | |
| 193 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | |
| 194 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | |
| 195 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | |
| 196 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | |
| 197 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | |
| 198 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | |
| 199 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | |
| 200 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | |
| 201 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | |
| 202 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | |
| 203 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | |
| 204 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | |
| 205 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | |
| 206 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | |
| 207 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | |
| 208 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | |
| 209 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | |
| 210 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | |
| 211 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 212 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 213 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 214 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 215 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 216 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 217 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 218 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 219 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 220 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 221 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 222 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 223 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 224 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 225 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 226 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 227 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 228 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 229 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 230 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 231 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 232 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 233 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 234 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 235 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 236 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 237 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 238 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 239 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 240 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 241 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 242 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 243 | IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</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 | |
| 244 | xAAADsQBlSsOGwAAPt9JREFUeF7t3bmqtnm57eG9NjZnYAeGamYDdqGGJiIidoGnoGBzDKKCzSFo | |
| 245 | ILaIgRoaqgg2oZmJimcgW6z93+uBl8lnrapa7fPbNa4vkM9Zc85n3Nd4g8E7Z+m/PPfcc//LHwIE | |
| 246 | CBAgQIAAgfsE/vd9j/ZkAgQIECBAgACB/ydgkHkdECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnX | |
| 247 | AAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wAB | |
| 248 | AgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIE | |
| 249 | CBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQ | |
| 250 | IEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA | |
| 251 | 4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBm | |
| 252 | AYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD | |
| 253 | 7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zm | |
| 254 | AjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8 | |
| 255 | ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4A | |
| 256 | AQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAEC | |
| 257 | BAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQI | |
| 258 | ECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAg | |
| 259 | YJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQ | |
| 260 | eQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkN | |
| 261 | ECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAg | |
| 262 | QIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECA | |
| 263 | AAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAAB | |
| 264 | AgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIE | |
| 265 | bhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4W | |
| 266 | MMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDI | |
| 267 | bi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4u | |
| 268 | wOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDj | |
| 269 | CRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQ | |
| 270 | IECAAAECBln3NfCNb3zjE5/4RCTfz372s1e+8pWRMGIQIECAAIGXmYBB9uKF/v73vz9b5Przu9/9 | |
| 271 | 7sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9 | |
| 272 | iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg | |
| 273 | e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz | |
| 274 | b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP | |
| 275 | b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 | |
| 276 | 4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ | |
| 277 | febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO | |
| 278 | uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ | |
| 279 | 3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z | |
| 280 | 487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh | |
| 281 | eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO | |
| 282 | J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq | |
| 283 | Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 | |
| 284 | 25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j | |
| 285 | rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm | |
| 286 | Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs | |
| 287 | Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z | |
| 288 | I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+ | |
| 289 | b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx | |
| 290 | Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb | |
| 291 | z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294 | |
| 292 | nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH | |
| 293 | zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6 | |
| 294 | /kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl | |
| 295 | LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf | |
| 296 | Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD | |
| 297 | bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb | |
| 298 | bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab | |
| 299 | dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd | |
| 300 | TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT | |
| 301 | IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI | |
| 302 | ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE | |
| 303 | CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC | |
| 304 | BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB | |
| 305 | AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA | |
| 306 | RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR | |
| 307 | MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM | |
| 308 | skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs | |
| 309 | U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU | |
| 310 | IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI | |
| 311 | QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ | |
| 312 | IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI | |
| 313 | ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE | |
| 314 | CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC | |
| 315 | BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB | |
| 316 | AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA | |
| 317 | qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq | |
| 318 | YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY | |
| 319 | ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ | |
| 320 | avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba | |
| 321 | vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv | |
| 322 | bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub | |
| 323 | AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA | |
| 324 | gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg | |
| 325 | QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ | |
| 326 | IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI | |
| 327 | EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE | |
| 328 | MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM | |
| 329 | gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg | |
| 330 | kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk | |
| 331 | mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm | |
| 332 | CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC | |
| 333 | EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE | |
| 334 | AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA | |
| 335 | gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg | |
| 336 | QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ | |
| 337 | IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI | |
| 338 | EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE | |
| 339 | VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV | |
| 340 | AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA | |
| 341 | IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI | |
| 342 | Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV | |
| 343 | 5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5 | |
| 344 | dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d | |
| 345 | BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB | |
| 346 | AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA | |
| 347 | AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA | |
| 348 | AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA | |
| 349 | gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg | |
| 350 | kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk | |
| 351 | BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB | |
| 352 | gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg | |
| 353 | y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy | |
| 354 | VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV | |
| 355 | ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug | |
| 356 | BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB | |
| 357 | AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA | |
| 358 | AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA | |
| 359 | AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA | |
| 360 | gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg | |
| 361 | sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis | |
| 362 | Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC | |
| 363 | Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB | |
| 364 | ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt | |
| 365 | Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN | |
| 366 | u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu | |
| 367 | JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ | |
| 368 | ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE | |
| 369 | CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC | |
| 370 | BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB | |
| 371 | AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA | |
| 372 | gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg | |
| 373 | I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI | |
| 374 | GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG | |
| 375 | WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW | |
| 376 | qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq | |
| 377 | EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE | |
| 378 | IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI | |
| 379 | ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE | |
| 380 | CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC | |
| 381 | BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB | |
| 382 | AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA | |
| 383 | gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg | |
| 384 | VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV | |
| 385 | MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM | |
| 386 | stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs | |
| 387 | tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt | |
| 388 | 3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3 | |
| 389 | NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N | |
| 390 | gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg | |
| 391 | QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ | |
| 392 | IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI | |
| 393 | ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE | |
| 394 | CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC | |
| 395 | GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG | |
| 396 | wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw | |
| 397 | yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy | |
| 398 | TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT | |
| 399 | hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh | |
| 400 | CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC | |
| 401 | gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg | |
| 402 | QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ | |
| 403 | IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI | |
| 404 | ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE | |
| 405 | CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC | |
| 406 | qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq | |
| 407 | gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg | |
| 408 | kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk | |
| 409 | q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq | |
| 410 | 8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8 | |
| 411 | uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u | |
| 412 | AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA | |
| 413 | AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA | |
| 414 | AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA | |
| 415 | gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg | |
| 416 | QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ | |
| 417 | yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy | |
| 418 | AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA | |
| 419 | QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ | |
| 420 | ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ | |
| 421 | KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK | |
| 422 | QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ | |
| 423 | AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA | |
| 424 | AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA | |
| 425 | AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA | |
| 426 | gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg | |
| 427 | QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ | |
| 428 | WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW | |
| 429 | BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB | |
| 430 | g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg | |
| 431 | W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW | |
| 432 | m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm | |
| 433 | 3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3 | |
| 434 | EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E | |
| 435 | CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC | |
| 436 | BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB | |
| 437 | AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA | |
| 438 | AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA | |
| 439 | QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ | |
| 440 | ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE | |
| 441 | DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD | |
| 442 | LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL | |
| 443 | VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV | |
| 444 | CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC | |
| 445 | ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE | |
| 446 | CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC | |
| 447 | BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB | |
| 448 | AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA | |
| 449 | AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA | |
| 450 | wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw | |
| 451 | KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK | |
| 452 | GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG | |
| 453 | 2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2 | |
| 454 | 2ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02 | |
| 455 | 724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827 | |
| 456 | mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m | |
| 457 | QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ | |
| 458 | IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI | |
| 459 | ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE | |
| 460 | CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC | |
| 461 | BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB | |
| 462 | jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj | |
| 463 | YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY | |
| 464 | ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ | |
| 465 | pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap | |
| 466 | QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ | |
| 467 | hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh | |
| 468 | QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ | |
| 469 | IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI | |
| 470 | ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE | |
| 471 | CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC | |
| 472 | BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB | |
| 473 | VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV | |
| 474 | wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw | |
| 475 | yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy | |
| 476 | 1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1 | |
| 477 | eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e | |
| 478 | 3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3 | |
| 479 | AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A | |
| 480 | AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA | |
| 481 | gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg | |
| 482 | QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ | |
| 483 | IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI | |
| 484 | ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ | |
| 485 | AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA | |
| 486 | IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI | |
| 487 | MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM | |
| 488 | FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF | |
| 489 | IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI | |
| 490 | AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA | |
| 491 | AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA | |
| 492 | gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg | |
| 493 | QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ | |
| 494 | ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI | |
| 495 | rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr | |
| 496 | AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA | |
| 497 | QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ | |
| 498 | rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr | |
| 499 | zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz | |
| 500 | 7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7 | |
| 501 | CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C | |
| 502 | BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB | |
| 503 | AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA | |
| 504 | AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA | |
| 505 | AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA | |
| 506 | ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI | |
| 507 | CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC | |
| 508 | BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB | |
| 509 | lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl | |
| 510 | qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq | |
| 511 | BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB | |
| 512 | CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC | |
| 513 | BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB | |
| 514 | AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA | |
| 515 | AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA | |
| 516 | AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA | |
| 517 | YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY | |
| 518 | FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF | |
| 519 | DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD | |
| 520 | bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb | |
| 521 | bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab | |
| 522 | dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd | |
| 523 | TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT | |
| 524 | IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI | |
| 525 | ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE | |
| 526 | CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/printout/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | |
| 3 | XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk | |
| 4 | otNUTao2FcavTMWPRFKzpATUYFCQhF0nriBgVJQUtOlvOY/ry+6y+/btvt33du+ObLK79+Pc83vn | |
| 5 | 3HPPvfecB7///vsHguU1MHi3+ZIVozFbe3pu3Mb/DA7ea26/InF8kePGJBviUTgjKTE8bLQxcXxs | |
| 6 | dITEuuov9qBGke67899WcxdQtFh7rN03zd/09Ny8owS7I8eGp0xOAOQG/fjMVIN24dcM0tbuG40t | |
| 7 | ly3WXqDb2tHZ1z+gBK5S2gwbPXpqsj7FkGDQx0D6k40TpNQKeBlVI2229ja2WJouXG780iJFZB8a | |
| 8 | 6E++btPeGdfMYf+7q++7jn/SWdwXPqZ1fCLKn48zDIwKs0bEWCNj3FaH0KcnJ0LcgXpm2iS35QNV | |
| 9 | QHVIY3499Xmb6YuOxi/NrgU3s/Mr440u482u5F4r3mP7bynExMaEpJ6xkeboCU0JSeao+J6xD7no | |
| 10 | CGBnT304PyvZqB+vED3ymlUL0lDOAPhY/cXmS84NqPB7dzM729O7LSm9V4w3rwFjeQP2vlZf+NjW | |
| 11 | mMTmeMOlmMTGhCkjAY95vTAntTAnTSWQBxhp6OdaU0ut6QImYEcMoHszui3p3ebMq18lX5dqQnuP | |
| 12 | pUctQMND6JsSpuDdqbbXx0bnz0ounj89sJAHDGkAfPiT5vpzHY5szb98PvfyeUiwR7OsR/AoVBjy | |
| 13 | DSmvm5Tx8aT0gVGj7XrJ+GliccF0CDqWcAoR4KJZfyONabj6xJma42ccLSwCGO+RA/3+Z4RvewTM | |
| 14 | psRHTk1Kx79b4WPFjcOCK8z5WdkTWX4Wcf8h3Wbu2nW4Horajqc5Vy4+1nE2OAB2+rhAqx9Jyqqd | |
| 15 | PMNOyiHiryz6RV5Wsm8fspFa8wfSwHh7zWkYXGIiYCovam8objNpTkXLAwZ2XO3k6XtT58GGF7cA | |
| 16 | r9ySkrl+wFtZpJ1iDCEuam8o7Dgrj2VarwWtfiRpFlAXDwRrs/LiuYoux5VCGkb11n2n7OQYKnpZ | |
| 17 | 04cBXCCp5ymB7bY7vWB/So5YpQPpZc/lQasrQafvkcY2w85D9TsPfjZ49y6jGBiXn61V7UpJCc5K | |
| 18 | adMp3iUFM5aX5UWO+5GUFqSX8THSpnNfV/211tpzg2MsHQNHvGGfLy/LL5k/Q3ojbkv6DGk4uTa8 | |
| 19 | e1S8PobTY+V/DuDdLRG8ADgAvN/KLsVCnHEDanzliwW+Uua+QRpL5I27TzJ1jZ0GzMclrSYOoacc | |
| 20 | gL22adbTYvt88VPZy57P87Qdx/LeIo194qp3j4pXyVg7VTQcDALvh/fMldcCbLQ9abk7phUyYw1i | |
| 21 | /cbyp/Rx0fIapFpeIY1F1NqtB5nLGkb12tP7uLr2Bg9WFy70quxSiDh9g5l79asLsV8iu3H5SNtp | |
| 22 | bIjy6vr92HGSTQqv6MiBnRkF22Y+yb5flJu++tcL5bnNZSK9/p0jh+vOEwWYlVfV7w9ZT4jSD2hz | |
| 23 | nGFd7ktslww+ta1/+KWMNZjHSGO5vHpzNbOxoavfqHsvRDyaSoM6UvvwpK6f8zwzy7HzvWnVs55O | |
| 24 | 254hDfur8i8fsMMCsK6hsQM1/lDrd2/avE2znqFRx0aN27jqOY+OsHmANFbMFW99wOyv8nO1S87W | |
| 25 | hhq7AzteeMvXzykjmxw22oaKUumucqlIA+YVf9zLnF+wsWGCBXbYodk7rPF1uS/SnjdOqVZVFOdM | |
| 26 | e1gKKyQhjeMDS19/j2CGdf1m3Xt5lwVzTEofvIxvOdAWk1i5oJzOr0GyYaBJUePukcbcvPxPf2+z | |
| 27 | XCOYN3z0N2w7+pZ03pqnHIApvqLwd2SQA+ztr7/i9gSLznUfsLTXbK7mMHuKhNLlsdjZeGIH1rfo | |
| 28 | CGelK6s+wPTqulM3SGNBhZsT1ATMbC7NSkMovX14JLccfZvAxsQKKwra10V1V0hvr/43WzcDZm6C | |
| 29 | SYfBPyWx3/9G3fvklwTYb77zoRyksWjGaQKqubj5JN+Y8g94nvYCLQvLiWrhhM/huuaRWnAu05ie | |
| 30 | 1205RLuQ8IJhC9JTCnh5v3EAYL/Q8gl1twHHQEaYsJ0jjY1IWlNhGoCz029E847kcWBZ05GUoTsu | |
| 31 | EM51Ww85bcQJ0tAAbPdiZcNB7tOWx31/1sJUzSZsTLswsBx7t0caenvb3o+pHEwwboX5EzBv+oIp | |
| 32 | vrzpCLUAA4tCQohf9khj15nu0dAJIW/65nX9zIGylk+YDt+x/7QrpCHQuw7WUwlsYCh3I9nPLAid | |
| 33 | 7laZhK3F6pNncCJoRJnetq+OBBoY82WVFp8PLJRwtJ4oxw0p50hDs//z+Bn6jZ8T0iLMgjIe3kq2 | |
| 34 | hZY49zUbyP15GiY3LaCh6/lWlXaRhuOMiXX1yfuX3+4jXXNc+Pbl5pPaHSen3GZjDYv1p2e/Zka4 | |
| 35 | gDS2MZirhD0RnGsa5QDEms5iQ0kfM7XQKASka04KM/Si9s/5SV6NAiwmG/eW6eP+E4KqtiENAf+o | |
| 36 | 4RL9wEoEwWhDeQhweZHE2mL1DYWDsiGN6G7MFuMXX4Pj+QDMzL95vN52RsiG9JftV2l4CCgTHOPk | |
| 37 | owAH2LGRVkungDSLlptxjd+ADZ6HhKnnL9qs8H7q8HexQ3Cb8ctzwYPzAw+wsKmYms1XenX4Y5M0 | |
| 38 | vwobTEhjLLThYTPFLlh0+KMPiNgYZOPkw5nZ2U5MaLN06W4NHyiceNtJ5E7OLE1zAFGSif7uG7d1 | |
| 39 | vd8Koe4jB77T9Kg48Y4coDPCeN2+M6Dr6xcA5rvRwfesRA4KSPdcv61jHnBujgUf0uwM4K07/bru | |
| 40 | b4UDR7HfKRXzPvg4qKERsRs9usGBexqim5PqKQfYfpVOPyGKKveF/SAMtact8vLq5ABdvsVNa11Y | |
| 41 | 2Cgi0S7guDrp5lR5xAEESKHycdEROvzRBykZgzzqhhcOOAduhY8hGiIjwnWx0ZGC9h7+NuD0cQJ8 | |
| 42 | xQFrhJDJ6aGIMfe199Xhb33VDW8n4BxArjeiQR8XpUsxCMkCKP0bfwUTB5DMj4Zj0Mfq0ocjxCOW | |
| 43 | nWPin2AadgiOpTneSKNGJkbM0xFI3YUPgNkcpY1knCGImYwhA1BIL1VMMU6wnS7KSNbT58aJU2S0 | |
| 44 | yKuokwPNcUZS0gg1ijijNqTTkwSkkaFTnURzqmRwgMktBBrVbUjnTBNE+dPER9haW0bTvIqqOHDM | |
| 45 | KCRqmplq0+E2pBG0DAJOU7VdHidVkc6Jkc4BJN6jFA9DyRVTBaTxH5bGBUm7pDfHS6qWA8ivSLQh | |
| 46 | hyZFfhdu6yDsP5zg+AxrzS4dn2oHwwkbiQNDmRVn0K8l8wUdLiAN22zhbCHlQ01yDmeipjlwOCmL | |
| 47 | Wd0seOz9W7VF86bS8JCCj4u1dpEGxrvSC4j+RbkZbCD3kUZM8Oxpk/EDioqzfGh3zKFJ+baZRbQn | |
| 48 | DYeYOIveD2IXrXgun7iDlA8w3kKTU5oeNTCGSqYhVLxcIM7C8wOkodORPpPKbf7505oec2gSv3nW | |
| 49 | M2yGtstpbR+PrLx0DhnhrTGJSPERmvzS6Kihhpk75LWlRXajsEcaGx5LSudQoa0zi7hpphXUKdMS | |
| 50 | UQtXiWO+BidxQ194PIvyo0IPrMlfzLcyNQE28p7S+TAkWFr50nxHmp0gjWl87dInSYdDpmHLaWKo | |
| 51 | oUwklPbhYefm2t8UQTFLQhqF4Alf82ohld6TNu+UKClyKDNUnWOnxNVEGwzqkZIqOZFpqoNFN3nG | |
| 52 | 8Xot9yVk7lHnOEOcKkzPv19QTie4kQURK6uRGDIi0qiAtKjsOAoSNPFjwmp7qmBCAReskkAYZtvX | |
| 53 | lj7pIo2tK6ThDN+4+llse6EhqIjK+eV891o9YNvs5Ud/xc4PIRke2dFyZJombGTKZNYZniBuiqsE | |
| 54 | bCS4ZHnIYUG7zXnoSqZpSHhS8LywPc3KBUu4ZAcWbAgbls7MSbLs+TzxToZMmaZqeF6YKd6YMGXJ | |
| 55 | Eyv4nB0osCFmUNpsTfXC47MWP5UthRj3eS1ZKwgLvX449xYCKGw8voMHJJTCYh+WAcwrFv6WTDC8 | |
| 56 | sKZaPbwYdtuLB0ijLQQHXzucR4tnM3XLXN8WEGctRcvlJXOWlMyV3oVnSKNdhButfOsfyJpJffB8 | |
| 57 | 49J57U1JcdpptAMTTMrcLO7RY6RRGak81myqYVnHMzu/QvY0HhDHGyBd1B06GFIETyWV8Si7uLdI | |
| 58 | oz5S6iHXGoUTxmsoVd77PJOtz8HGvgM2mdiOInYv/lxR4nrdPBINcmSatbVt36mdh4SsS/gSiU6h | |
| 59 | zHkgeF/hvTOjYMe0QubAwMkCeMHgzpLXvldIo0ukdVi35QDlXsILmdgqGg5y4ZYHBquFXYaqnFLm | |
| 60 | /4LGrny5QHwoTEb73iKNLpHgeu3WAyxTNb4p7Di7suEAn7ll4IF11K70RyHNrC72LaoqSuCslNGa | |
| 61 | D+Zpx16x2t60+zizyTFzQ5Mj/56X9IVUdRjYcH7RyU56wSuC1ZSLfQvp/PGBTLPOEK5w276PWZ5b | |
| 62 | fI+ow8jow7NvucUDR8B2TC+E/5GVxKFs7CV6L8qsQV8iTY1i5t7w7lEk+mB9AG/k4IJKdzvgECzg | |
| 63 | iDEM7GVl+Z4ul92yzvdIU5ewybfvP00x4umFGJbQ5zzNMWOII8b4CQ7O5WV5sg1sF3grhTS6hDLf | |
| 64 | fegz5Mq0w7u4zVTaagrlgLTYhqpJmS3W1WDXotz08pK5+jhbJBIlXgoiTeQ6xRtr7sKOM8WtppDK | |
| 65 | /AHHNW43YhtKbHP5AWMCQnGkXeCNn4A08AbqQexvgesDt59wMZ0dHGAiq7Qci3WDn5CmLrHyrjW1 | |
| 66 | 7PnX52J7Dd8D5tlXLiJbF1JqBo1WJ4DrJmXg3e6gDk7nFS+YDpvL6XFdJVS3/2Tajno4zJFJ8+in | |
| 67 | F8VTOJWBfw2rMvzTqOPFBcAYHW6zlhbMsLsxpRC0ds36Vabt+iYRrz5xps1yzXG0cKxilwzZYfCu | |
| 68 | ctSHIn8ZESsIwZ/s7CwaF4S4cHZq8fzpyhlcbh+XQCLNiMPOGM44nGq8hLW4U4oZ6pjXWcYBt2NT | |
| 69 | tADclvBLNyVMaZyYxBzUdj3CkYkz84gs4kMHiOxBqQJpRj0MdUB+rP7CSJDTpA68gb3xZheyBMEt | |
| 70 | 44epnaTWHB1viZqAAKttMXoX4dCxq/hY9iNQ0QGUYMcHQl1IM/qQgxFgI+Fm04XLLlCn8tDtxps2 | |
| 71 | /Z/SeyVisN/2KAwl6MSj4KnaxyYSIOwZG0lbwhSKzxwVb7cucuQjwnxlpRlnpv4kM82ghN9Dtiiz | |
| 72 | iipF2m5gABu5+oB6a0cn20TxfvBetgDXdIohQc3oigeoDaTFFMOOazV3tVk6r3bfQr5dv2EPnYwo | |
| 73 | 2Zh6M1MN+vgoVWlmKY+s9pB2HBVUffMlW1o/s7UHM/3g4D3Ks2z+pocdkZDCC2z4Tx0KlmucGDv+ | |
| 74 | x+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 | |
| 3 | href="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 | |
| 4 | xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr | |
| 5 | oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 6 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 7 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 8 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 9 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 10 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 11 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 12 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 13 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 14 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 15 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 16 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 17 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 18 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 19 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 20 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 21 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 22 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 23 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 24 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 25 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 26 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 27 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 28 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 29 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 30 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 31 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 32 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 33 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 34 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 35 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 36 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 37 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | |
| 38 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | |
| 39 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | |
| 40 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | |
| 41 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | |
| 42 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | |
| 43 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | |
| 44 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | |
| 45 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | |
| 46 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | |
| 47 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | |
| 48 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | |
| 49 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | |
| 50 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | |
| 51 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | |
| 52 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | |
| 53 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | |
| 54 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | |
| 55 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | |
| 56 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | |
| 57 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | |
| 58 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | |
| 59 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | |
| 60 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | |
| 61 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | |
| 62 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | |
| 63 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | |
| 64 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | |
| 65 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | |
| 66 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | |
| 67 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | |
| 68 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | |
| 69 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | |
| 70 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | |
| 71 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | |
| 72 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | |
| 73 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | |
| 74 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | |
| 75 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | |
| 76 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | |
| 77 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | |
| 78 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | |
| 79 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | |
| 80 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | |
| 81 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | |
| 82 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | |
| 83 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | |
| 84 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | |
| 85 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | |
| 86 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | |
| 87 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | |
| 88 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | |
| 89 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | |
| 90 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | |
| 91 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | |
| 92 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | |
| 93 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | |
| 94 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | |
| 95 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | |
| 96 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | |
| 97 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | |
| 98 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | |
| 99 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | |
| 100 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | |
| 101 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | |
| 102 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | |
| 103 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | |
| 104 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | |
| 105 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | |
| 106 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | |
| 107 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | |
| 108 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 109 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 110 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 111 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 112 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 113 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 114 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 115 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 116 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 117 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 118 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 119 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 120 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 121 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 122 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 123 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 124 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 125 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 126 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 127 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 128 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 129 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 130 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 131 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 132 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 133 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 134 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 135 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 136 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 137 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 138 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 139 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 140 | IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD | |
| 141 | LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg | |
| 142 | QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA | |
| 143 | wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA | |
| 144 | AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA | |
| 145 | gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 | |
| 146 | APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB | |
| 147 | AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD | |
| 148 | zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE | |
| 149 | CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi | |
| 150 | AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE | |
| 151 | EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ | |
| 152 | IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ | |
| 153 | QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA | |
| 154 | gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM | |
| 155 | srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A | |
| 156 | AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA | |
| 157 | AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB | |
| 158 | AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC | |
| 159 | BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji | |
| 160 | AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE | |
| 161 | CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM | |
| 162 | Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ | |
| 163 | IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI | |
| 164 | BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ | |
| 165 | T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA | |
| 166 | gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 | |
| 167 | AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA | |
| 168 | AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw | |
| 169 | yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB | |
| 170 | AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC | |
| 171 | BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME | |
| 172 | CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI | |
| 173 | EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL | |
| 174 | CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ | |
| 175 | IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw | |
| 176 | yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA | |
| 177 | gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg | |
| 178 | FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A | |
| 179 | PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA | |
| 180 | AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz | |
| 181 | AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC | |
| 182 | BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA | |
| 183 | IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE | |
| 184 | CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI | |
| 185 | EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q | |
| 186 | IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg | |
| 187 | QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs | |
| 188 | LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA | |
| 189 | gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA | |
| 190 | IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA | |
| 191 | AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB | |
| 192 | WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA | |
| 193 | 8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC | |
| 194 | BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM | |
| 195 | DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI | |
| 196 | ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB | |
| 197 | gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT | |
| 198 | IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg | |
| 199 | QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A | |
| 200 | gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA | |
| 201 | AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy | |
| 202 | uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA | |
| 203 | AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB | |
| 204 | g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC | |
| 205 | BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE | |
| 206 | YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC | |
| 207 | xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI | |
| 208 | ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy | |
| 209 | P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg | |
| 210 | QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF | |
| 211 | DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP | |
| 212 | gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA | |
| 213 | AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA | |
| 214 | AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB | |
| 215 | AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI | |
| 216 | 4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC | |
| 217 | BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE | |
| 218 | DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI | |
| 219 | ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ | |
| 220 | iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL | |
| 221 | EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg | |
| 222 | QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI | |
| 223 | /AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA | |
| 224 | AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW | |
| 225 | MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 | |
| 226 | AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB | |
| 227 | AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD | |
| 228 | BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE | |
| 229 | CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg | |
| 230 | iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI | |
| 231 | ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ | |
| 232 | MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg | |
| 233 | QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA | |
| 234 | IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu | |
| 235 | QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA | |
| 236 | AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg | |
| 237 | 8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB | |
| 238 | AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY | |
| 239 | wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx | |
| 240 | BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE | |
| 241 | CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP | |
| 242 | ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ | |
| 243 | IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</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 | |
| 244 | xAAADsQBlSsOGwAAPt9JREFUeF7t3bmqtnm57eG9NjZnYAeGamYDdqGGJiIidoGnoGBzDKKCzSFo | |
| 245 | ILaIgRoaqgg2oZmJimcgW6z93+uBl8lnrapa7fPbNa4vkM9Zc85n3Nd4g8E7Z+m/PPfcc//LHwIE | |
| 246 | CBAgQIAAgfsE/vd9j/ZkAgQIECBAgACB/ydgkHkdECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnX | |
| 247 | AAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wAB | |
| 248 | AgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIE | |
| 249 | CBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQ | |
| 250 | IEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA | |
| 251 | 4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBm | |
| 252 | AYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD | |
| 253 | 7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zm | |
| 254 | AjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8 | |
| 255 | ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4A | |
| 256 | AQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAEC | |
| 257 | BAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQI | |
| 258 | ECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAg | |
| 259 | YJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQ | |
| 260 | eQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkN | |
| 261 | ECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAg | |
| 262 | QIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECA | |
| 263 | AAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAAB | |
| 264 | AgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIE | |
| 265 | bhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4W | |
| 266 | MMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDI | |
| 267 | bi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4u | |
| 268 | wOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDj | |
| 269 | CRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQ | |
| 270 | IECAAAECBln3NfCNb3zjE5/4RCTfz372s1e+8pWRMGIQIECAAIGXmYBB9uKF/v73vz9b5Przu9/9 | |
| 271 | 7sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9 | |
| 272 | iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg | |
| 273 | e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz | |
| 274 | b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP | |
| 275 | b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 | |
| 276 | 4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ | |
| 277 | febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO | |
| 278 | uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ | |
| 279 | 3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z | |
| 280 | 487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh | |
| 281 | eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO | |
| 282 | J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq | |
| 283 | Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 | |
| 284 | 25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j | |
| 285 | rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm | |
| 286 | Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs | |
| 287 | Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z | |
| 288 | I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+ | |
| 289 | b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx | |
| 290 | Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb | |
| 291 | z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294 | |
| 292 | nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH | |
| 293 | zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6 | |
| 294 | /kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl | |
| 295 | LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf | |
| 296 | Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD | |
| 297 | bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb | |
| 298 | bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab | |
| 299 | dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd | |
| 300 | TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT | |
| 301 | IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI | |
| 302 | ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE | |
| 303 | CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC | |
| 304 | BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB | |
| 305 | AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA | |
| 306 | RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR | |
| 307 | MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM | |
| 308 | skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs | |
| 309 | U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU | |
| 310 | IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI | |
| 311 | QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ | |
| 312 | IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI | |
| 313 | ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE | |
| 314 | CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC | |
| 315 | BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB | |
| 316 | AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA | |
| 317 | qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq | |
| 318 | YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY | |
| 319 | ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ | |
| 320 | avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba | |
| 321 | vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv | |
| 322 | bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub | |
| 323 | AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA | |
| 324 | gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg | |
| 325 | QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ | |
| 326 | IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI | |
| 327 | EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE | |
| 328 | MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM | |
| 329 | gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg | |
| 330 | kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk | |
| 331 | mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm | |
| 332 | CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC | |
| 333 | EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE | |
| 334 | AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA | |
| 335 | gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg | |
| 336 | QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ | |
| 337 | IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI | |
| 338 | EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE | |
| 339 | VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV | |
| 340 | AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA | |
| 341 | IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI | |
| 342 | Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV | |
| 343 | 5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5 | |
| 344 | dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d | |
| 345 | BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB | |
| 346 | AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA | |
| 347 | AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA | |
| 348 | AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA | |
| 349 | gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg | |
| 350 | kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk | |
| 351 | BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB | |
| 352 | gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg | |
| 353 | y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy | |
| 354 | VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV | |
| 355 | ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug | |
| 356 | BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB | |
| 357 | AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA | |
| 358 | AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA | |
| 359 | AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA | |
| 360 | gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg | |
| 361 | sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis | |
| 362 | Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC | |
| 363 | Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB | |
| 364 | ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt | |
| 365 | Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN | |
| 366 | u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu | |
| 367 | JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ | |
| 368 | ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE | |
| 369 | CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC | |
| 370 | BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB | |
| 371 | AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA | |
| 372 | gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg | |
| 373 | I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI | |
| 374 | GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG | |
| 375 | WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW | |
| 376 | qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq | |
| 377 | EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE | |
| 378 | IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI | |
| 379 | ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE | |
| 380 | CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC | |
| 381 | BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB | |
| 382 | AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA | |
| 383 | gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg | |
| 384 | VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV | |
| 385 | MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM | |
| 386 | stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs | |
| 387 | tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt | |
| 388 | 3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3 | |
| 389 | NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N | |
| 390 | gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg | |
| 391 | QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ | |
| 392 | IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI | |
| 393 | ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE | |
| 394 | CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC | |
| 395 | GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG | |
| 396 | wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw | |
| 397 | yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy | |
| 398 | TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT | |
| 399 | hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh | |
| 400 | CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC | |
| 401 | gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg | |
| 402 | QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ | |
| 403 | IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI | |
| 404 | ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE | |
| 405 | CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC | |
| 406 | qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq | |
| 407 | gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg | |
| 408 | kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk | |
| 409 | q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq | |
| 410 | 8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8 | |
| 411 | uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u | |
| 412 | AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA | |
| 413 | AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA | |
| 414 | AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA | |
| 415 | gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg | |
| 416 | QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ | |
| 417 | yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy | |
| 418 | AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA | |
| 419 | QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ | |
| 420 | ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ | |
| 421 | KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK | |
| 422 | QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ | |
| 423 | AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA | |
| 424 | AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA | |
| 425 | AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA | |
| 426 | gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg | |
| 427 | QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ | |
| 428 | WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW | |
| 429 | BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB | |
| 430 | g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg | |
| 431 | W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW | |
| 432 | m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm | |
| 433 | 3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3 | |
| 434 | EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E | |
| 435 | CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC | |
| 436 | BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB | |
| 437 | AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA | |
| 438 | AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA | |
| 439 | QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ | |
| 440 | ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE | |
| 441 | DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD | |
| 442 | LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL | |
| 443 | VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV | |
| 444 | CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC | |
| 445 | ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE | |
| 446 | CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC | |
| 447 | BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB | |
| 448 | AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA | |
| 449 | AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA | |
| 450 | wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw | |
| 451 | KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK | |
| 452 | GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG | |
| 453 | 2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2 | |
| 454 | 2ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02 | |
| 455 | 724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827 | |
| 456 | mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m | |
| 457 | QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ | |
| 458 | IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI | |
| 459 | ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE | |
| 460 | CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC | |
| 461 | BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB | |
| 462 | jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj | |
| 463 | YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY | |
| 464 | ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ | |
| 465 | pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap | |
| 466 | QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ | |
| 467 | hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh | |
| 468 | QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ | |
| 469 | IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI | |
| 470 | ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE | |
| 471 | CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC | |
| 472 | BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB | |
| 473 | VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV | |
| 474 | wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw | |
| 475 | yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy | |
| 476 | 1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1 | |
| 477 | eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e | |
| 478 | 3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3 | |
| 479 | AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A | |
| 480 | AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA | |
| 481 | gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg | |
| 482 | QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ | |
| 483 | IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI | |
| 484 | ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ | |
| 485 | AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA | |
| 486 | IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI | |
| 487 | MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM | |
| 488 | FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF | |
| 489 | IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI | |
| 490 | AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA | |
| 491 | AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA | |
| 492 | gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg | |
| 493 | QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ | |
| 494 | ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI | |
| 495 | rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr | |
| 496 | AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA | |
| 497 | QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ | |
| 498 | rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr | |
| 499 | zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz | |
| 500 | 7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7 | |
| 501 | CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C | |
| 502 | BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB | |
| 503 | AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA | |
| 504 | AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA | |
| 505 | AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA | |
| 506 | ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI | |
| 507 | CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC | |
| 508 | BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB | |
| 509 | lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl | |
| 510 | qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq | |
| 511 | BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB | |
| 512 | CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC | |
| 513 | BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB | |
| 514 | AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA | |
| 515 | AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA | |
| 516 | AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA | |
| 517 | YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY | |
| 518 | FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF | |
| 519 | DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD | |
| 520 | bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb | |
| 521 | bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab | |
| 522 | dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd | |
| 523 | TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT | |
| 524 | IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI | |
| 525 | ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE | |
| 526 | CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/printout/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | 32 | click plays from the linked moment. |
| 33 | 33 | - **Deleting** the recording removes AudioRecordingGuids from the page; the |
| 34 | 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 | 59 | - **Attach File** of a .wav makes it a recording as well: identity, |
| 36 | 60 | IRecordMedia 1 and its length (1,000 ms), listed on the page |
| 37 | 61 | (`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 | 86 | `tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook |
| 63 | 87 | --screenshots`. |
| 64 | 88 | |
| 89 | `video/` is Record Video. `native/read/attached-video.xml` is OneNote | |
| 90 | attaching one file of each kind: .avi, .mpg and .wmv become recordings (kind 2, | |
| 91 | with their length), .mp4 and .mov plain files. OneNote played Motion JPEG AVI | |
| 92 | with 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` | |
| 94 | is `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, | |
| 96 | 15 a second, with 16 kHz PCM, as Snowbound records video. `video/cold` is a | |
| 97 | fresh OneNote 2010 read; OneNote played it from a linked note with See Playback | |
| 98 | highlighting it (`video/played-from-note.png`), and played what the macOS | |
| 99 | conversion makes of `video/camera.mov` (H.264 and AAC, as a capture session | |
| 100 | records) 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 | 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/recording/video/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/recording/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 | |
| 5 | Important, the last also holding a To Do-tagged table in a cell. OneNote stores a | |
| 6 | table'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 | |
| 10 | box; on "Edit a tagged table" text is typed and Enter pressed in a cell; "Delete and | |
| 11 | undo" has all its content deleted and the deletion undone, each published as its own | |
| 12 | revision. | |
| 13 | ||
| 14 | `cold/` is a fresh OneNote 2010 read: the first table's To Do is completed, the edited | |
| 15 | table 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, | |
| 18 | then 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/table-tags/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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' | |
| 7 | New-Item -ItemType Directory -Path $inputs | Out-Null | |
| 8 | $app = New-Object -ComObject OneNote.Application | |
| 9 | $notebookId = '' | |
| 10 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/table-tags/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 | 20 | |
| 21 | 21 | accesskit = { version = "0.25.0", optional = true } |
| 22 | 22 | color_quant = { version = "1.1", optional = true } |
| 23 | image = { version = "0.25", default-features = false, features = ["jpeg"], optional = true } | |
| 23 | image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true } | |
| 24 | 24 | png = { version = "0.18", optional = true } |
| 25 | 25 | resvg = { version = "0.48.1", default-features = false, optional = true } |
| 26 | 26 | sha2 = { 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 | ||
| 8 | use canvas::{ | |
| 9 | document::TextPosition, | |
| 10 | editor::{CanvasEditor, EditorError, TextOutline}, | |
| 11 | layout::{LayoutError, TextEngine}, | |
| 12 | outline::Arrange, | |
| 13 | }; | |
| 14 | use onestore::{ | |
| 15 | Arena, ExGuid, Section, | |
| 16 | page::{ | |
| 17 | Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, | |
| 18 | text::{Affinity, EditError}, | |
| 19 | }, | |
| 20 | }; | |
| 21 | use std::{ | |
| 22 | collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, | |
| 23 | hash::{Hash, Hasher}, | |
| 24 | path::{Path, PathBuf}, | |
| 25 | }; | |
| 26 | ||
| 27 | #[derive(Default)] | |
| 28 | struct 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)] | |
| 36 | enum 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 | ||
| 47 | impl 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. | |
| 57 | fn 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 | ||
| 82 | fn 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. | |
| 91 | fn 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. | |
| 115 | fn 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. | |
| 185 | fn 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. | |
| 230 | fn 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. | |
| 262 | fn 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. | |
| 291 | fn 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)] | |
| 420 | enum Probe { | |
| 421 | Type, | |
| 422 | Enter, | |
| 423 | Delete, | |
| 424 | Backspace, | |
| 425 | } | |
| 426 | ||
| 427 | /// Each paragraph's table cell, `None` at the outline's top. | |
| 428 | fn 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 | ||
| 444 | fn 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 | ||
| 528 | fn 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 | ||
| 575 | fn 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 | ||
| 590 | fn 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 | 1 | use onestore::page::text::{EditError, Paragraph, new_id}; |
| 2 | use onestore::page::{PageParagraph, ParagraphContent, TextObject}; | |
| 2 | use onestore::page::{PageParagraph, ParagraphContent, TableCell, TextObject}; | |
| 3 | 3 | use onestore::{ExGuid, document::Format}; |
| 4 | 4 | use std::{ |
| 5 | 5 | collections::{BTreeMap, BTreeSet}, |
| ... | ... | @@ -247,10 +247,110 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> { |
| 247 | 247 | Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}')) |
| 248 | 248 | } |
| 249 | 249 | |
| 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. | |
| 257 | fn 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 | 350 | /// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets |
| 251 | 351 | /// 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 at | |
| 253 | /// its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`). | |
| 352 | /// equation's objects: the object's run data belongs to its paragraph, and an equation divides | |
| 353 | /// only at its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`). | |
| 254 | 354 | fn moves_object( |
| 255 | 355 | first: &Paragraph, |
| 256 | 356 | start: u32, |
| ... | ... | @@ -266,9 +366,21 @@ fn moves_object( |
| 266 | 366 | .chars() |
| 267 | 367 | .next() |
| 268 | 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 | 378 | Ok(first.text()[..before].ends_with('\u{fffc}') |
| 270 | 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 | } |
| 273 | 385 | |
| 274 | 386 | /// Paragraph positions of each node's first text leaf, counting from `first`. |
| ... | ... | @@ -689,15 +801,15 @@ impl TextDocument { |
| 689 | 801 | if range.start > range.end { |
| 690 | 802 | return Err(EditError::InvalidRange); |
| 691 | 803 | } |
| 804 | if self.crosses_table(range.clone())? { | |
| 805 | return self.replace_across(range, replacement); | |
| 806 | } | |
| 692 | 807 | let (container, start, first) = self |
| 693 | 808 | .leaf(range.start.paragraph) |
| 694 | 809 | .ok_or(EditError::InvalidRange)?; |
| 695 | let (end_container, end, last) = self | |
| 810 | let (_, end, last) = self | |
| 696 | 811 | .leaf(range.end.paragraph) |
| 697 | 812 | .ok_or(EditError::InvalidRange)?; |
| 698 | if container != end_container { | |
| 699 | return Err(EditError::UnsupportedContent); | |
| 700 | } | |
| 701 | 813 | let nodes = self.container(container)?; |
| 702 | 814 | let first_text = &first.text().unwrap().text; |
| 703 | 815 | let last_text = &last.text().unwrap().text; |
| ... | ... | @@ -763,6 +875,66 @@ impl TextDocument { |
| 763 | 875 | }) |
| 764 | 876 | } |
| 765 | 877 | |
| 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 | 938 | /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties |
| 767 | 939 | /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree |
| 768 | 940 | /// lies between them in one container. The lower text keeps its look under `base`, the |
| ... | ... | @@ -1245,9 +1417,9 @@ mod tests { |
| 1245 | 1417 | .paragraphs() |
| 1246 | 1418 | .map(Paragraph::text) |
| 1247 | 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 | } |
| 1252 | 1424 | |
| 1253 | 1425 | #[test] |
| ... | ... | @@ -1259,9 +1431,17 @@ mod tests { |
| 1259 | 1431 | document.replace(position(1, 2)..position(1, 3), text()), |
| 1260 | 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 | 1439 | assert_eq!( |
| 1263 | document.replace(position(1, 0)..position(3, 1), text()), | |
| 1264 | Err(EditError::UnsupportedContent) | |
| 1440 | deleted | |
| 1441 | .paragraphs() | |
| 1442 | .map(Paragraph::text) | |
| 1443 | .collect::<Vec<_>>(), | |
| 1444 | ["before", "aX", "ight", "after"], | |
| 1265 | 1445 | ); |
| 1266 | 1446 | let ParagraphContent::Table(table) = &document.nodes()[1].content else { |
| 1267 | 1447 | panic!() |
| ... | ... | @@ -1778,9 +1958,43 @@ mod tests { |
| 1778 | 1958 | for replacement in &replacements { |
| 1779 | 1959 | let mut document = original.clone(); |
| 1780 | 1960 | let planned = document.replace(start..end, replacement.clone()); |
| 1781 | if regions[start.paragraph] != regions[end.paragraph] { | |
| 1782 | assert_eq!(planned, Err(EditError::UnsupportedContent)); | |
| 1961 | if regions[start.paragraph..=end.paragraph] | |
| 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 | 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 | 1998 | continue; |
| 1785 | 1999 | } |
| 1786 | 2000 | let planned = planned.unwrap(); |
crates/canvas/src/editor.rs+184-11| ... | ... | @@ -126,6 +126,10 @@ pub struct CanvasEditor { |
| 126 | 126 | recording: Option<recording::Live>, |
| 127 | 127 | /// Drawings being dragged and how far, shown there until dropped. |
| 128 | 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 | } |
| 130 | 134 | |
| 131 | 135 | /// Imported page state the editable content does not carry. |
| ... | ... | @@ -280,9 +284,10 @@ impl TextOutline { |
| 280 | 284 | outline.layout(engine, definitions)? |
| 281 | 285 | }; |
| 282 | 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 | 289 | if document.text_nodes().next().is_none() { |
| 285 | return Err(EditError::UnsupportedContent.into()); | |
| 290 | return Err(EditError::InvalidStructure.into()); | |
| 286 | 291 | } |
| 287 | 292 | Ok(Self { |
| 288 | 293 | id: outline.id, |
| ... | ... | @@ -532,6 +537,23 @@ impl TextOutline { |
| 532 | 537 | Ok(rectangles) |
| 533 | 538 | } |
| 534 | 539 | |
| 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 | 557 | /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the |
| 536 | 558 | /// next shown one. |
| 537 | 559 | pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> { |
| ... | ... | @@ -889,6 +911,7 @@ impl CanvasEditor { |
| 889 | 911 | typing: None, |
| 890 | 912 | recording: None, |
| 891 | 913 | ink_drag: None, |
| 914 | provisional: BTreeMap::new(), | |
| 892 | 915 | }) |
| 893 | 916 | } |
| 894 | 917 | |
| ... | ... | @@ -899,6 +922,7 @@ impl CanvasEditor { |
| 899 | 922 | mut outlines, |
| 900 | 923 | date, |
| 901 | 924 | areas, |
| 925 | provisional, | |
| 902 | 926 | } = page::build(&mut page, engine, true)?; |
| 903 | 927 | let needs_caret = outlines.is_empty(); |
| 904 | 928 | if needs_caret { |
| ... | ... | @@ -926,6 +950,10 @@ impl CanvasEditor { |
| 926 | 950 | }; |
| 927 | 951 | } |
| 928 | 952 | editor.objects = objects; |
| 953 | editor.provisional = provisional | |
| 954 | .into_iter() | |
| 955 | .map(|(outline, blank)| (outline, (blank, false))) | |
| 956 | .collect(); | |
| 929 | 957 | editor.header = PageHeader { |
| 930 | 958 | title: page.title, |
| 931 | 959 | identity: page.identity, |
| ... | ... | @@ -1030,6 +1058,10 @@ impl CanvasEditor { |
| 1030 | 1058 | && let Some(kept) = own.remove(&outline.id) |
| 1031 | 1059 | { |
| 1032 | 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 | 1067 | let position = fresh.outlines.iter().position(|outline| outline.id == id); |
| ... | ... | @@ -1132,7 +1164,13 @@ impl CanvasEditor { |
| 1132 | 1164 | let mut outline = match content { |
| 1133 | 1165 | page::Content::Editable(id) => { |
| 1134 | 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 | 1174 | None => continue, |
| 1137 | 1175 | } |
| 1138 | 1176 | } |
| ... | ... | @@ -1469,6 +1507,7 @@ impl CanvasEditor { |
| 1469 | 1507 | typing: None, |
| 1470 | 1508 | recording: None, |
| 1471 | 1509 | ink_drag: None, |
| 1510 | provisional: BTreeMap::new(), | |
| 1472 | 1511 | }) |
| 1473 | 1512 | } |
| 1474 | 1513 | |
| ... | ... | @@ -1647,6 +1686,16 @@ impl CanvasEditor { |
| 1647 | 1686 | } |
| 1648 | 1687 | |
| 1649 | 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 | 1699 | pub fn caret_outline(&self) -> Option<&TextOutline> { |
| 1651 | 1700 | match &self.active { |
| 1652 | 1701 | Focus::Caret { outline, .. } => Some(outline), |
| ... | ... | @@ -2192,6 +2241,12 @@ impl CanvasEditor { |
| 2192 | 2241 | self.redo.clear(); |
| 2193 | 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 | 2250 | let [anchor, focus] = self.selection().positions; |
| 2196 | 2251 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 2197 | 2252 | let mut format = self.typing_format(start)?; |
| ... | ... | @@ -2261,7 +2316,19 @@ impl CanvasEditor { |
| 2261 | 2316 | }) |
| 2262 | 2317 | .ok_or(EditError::InvalidRange)?; |
| 2263 | 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 | 2332 | self.undo.push(History::Picture { |
| 2266 | 2333 | index, |
| 2267 | 2334 | image: Some(Box::new(image)), |
| ... | ... | @@ -2484,6 +2551,34 @@ impl CanvasEditor { |
| 2484 | 2551 | result |
| 2485 | 2552 | } |
| 2486 | 2553 | |
| 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 | 2582 | /// Runs `edit`, typing or a backspace at a caret, as part of the typing run the last undo |
| 2488 | 2583 | /// step holds if the caret is where that run left it and nothing came between; OneNote 2010 |
| 2489 | 2584 | /// undoes a run whole however long it pauses. |
| ... | ... | @@ -3149,6 +3244,21 @@ impl CanvasEditor { |
| 3149 | 3244 | ) |
| 3150 | 3245 | } |
| 3151 | 3246 | |
| 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 | 3262 | /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line |
| 3153 | 3263 | /// of text joins the typing run before it. |
| 3154 | 3264 | pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { |
| ... | ... | @@ -3210,6 +3320,11 @@ impl CanvasEditor { |
| 3210 | 3320 | format.language = Some(language); |
| 3211 | 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 | 3328 | let edge = Paragraph::new(String::new(), self.typing_format(start)?); |
| 3214 | 3329 | let pasted = Format { |
| 3215 | 3330 | font: Some("Calibri".into()), |
| ... | ... | @@ -3252,6 +3367,9 @@ impl CanvasEditor { |
| 3252 | 3367 | /// start its tags stay with its text below; an empty paragraph opens a plain one above and |
| 3253 | 3368 | /// leaves the list it ends; a heading continues as body text. |
| 3254 | 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 | 3373 | let [anchor, focus] = self.active_outline().selection.positions; |
| 3256 | 3374 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 3257 | 3375 | let mut format = self.typing_format(start)?; |
| ... | ... | @@ -3339,8 +3457,9 @@ impl CanvasEditor { |
| 3339 | 3457 | .document |
| 3340 | 3458 | .leaf(end.paragraph) |
| 3341 | 3459 | .ok_or(EditError::InvalidRange)?; |
| 3460 | // OneNote 2010's Tab leaves a selection crossing a table's edge alone. | |
| 3342 | 3461 | if container != end_container { |
| 3343 | return Err(EditError::UnsupportedContent.into()); | |
| 3462 | return Ok(false); | |
| 3344 | 3463 | } |
| 3345 | 3464 | let range = local_start |
| 3346 | 3465 | ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph); |
| ... | ... | @@ -3617,6 +3736,7 @@ impl CanvasEditor { |
| 3617 | 3736 | }; |
| 3618 | 3737 | match self.apply_history(engine, history) { |
| 3619 | 3738 | Ok(inverse) => { |
| 3739 | self.unstore_provisional(); | |
| 3620 | 3740 | let page = matches!(inverse, History::Group { page: true, .. }); |
| 3621 | 3741 | self.redo.push(inverse); |
| 3622 | 3742 | if page { |
| ... | ... | @@ -3631,6 +3751,37 @@ impl CanvasEditor { |
| 3631 | 3751 | } |
| 3632 | 3752 | } |
| 3633 | 3753 | |
| 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 | 3785 | pub fn redo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { |
| 3635 | 3786 | self.finish_composition(); |
| 3636 | 3787 | let Some(history) = self.redo.pop() else { |
| ... | ... | @@ -5994,8 +6145,10 @@ mod tests { |
| 5994 | 6145 | ..Default::default() |
| 5995 | 6146 | }, |
| 5996 | 6147 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), |
| 6148 | display: None, | |
| 5997 | 6149 | alt: None, |
| 5998 | 6150 | background, |
| 6151 | printout: None, | |
| 5999 | 6152 | }); |
| 6000 | 6153 | let (id, background_id) = (picture.id, background.id); |
| 6001 | 6154 | let mut editor = CanvasEditor::from_page( |
| ... | ... | @@ -6100,8 +6253,10 @@ mod tests { |
| 6100 | 6253 | ..Default::default() |
| 6101 | 6254 | }, |
| 6102 | 6255 | bytes: Some(std::sync::Arc::from(bytes)), |
| 6256 | display: None, | |
| 6103 | 6257 | alt: None, |
| 6104 | 6258 | background, |
| 6259 | printout: None, | |
| 6105 | 6260 | }; |
| 6106 | 6261 | let [old, picture] = [image(true, b"old art"), image(false, b"picture")]; |
| 6107 | 6262 | let mut editor = CanvasEditor::from_page( |
| ... | ... | @@ -6187,8 +6342,10 @@ mod tests { |
| 6187 | 6342 | ..Default::default() |
| 6188 | 6343 | }, |
| 6189 | 6344 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), |
| 6345 | display: None, | |
| 6190 | 6346 | alt: None, |
| 6191 | 6347 | background: false, |
| 6348 | printout: None, | |
| 6192 | 6349 | }; |
| 6193 | 6350 | let id = picture.id; |
| 6194 | 6351 | let objects = vec![ |
| ... | ... | @@ -6250,8 +6407,10 @@ mod tests { |
| 6250 | 6407 | id: onestore::page::text::new_id().unwrap(), |
| 6251 | 6408 | layout: Default::default(), |
| 6252 | 6409 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), |
| 6410 | display: None, | |
| 6253 | 6411 | alt: None, |
| 6254 | 6412 | background: false, |
| 6413 | printout: None, | |
| 6255 | 6414 | }; |
| 6256 | 6415 | picture.content = ParagraphContent::Image(image.clone()); |
| 6257 | 6416 | source.paragraphs.insert(1, picture); |
| ... | ... | @@ -6423,7 +6582,9 @@ mod tests { |
| 6423 | 6582 | |
| 6424 | 6583 | editor.select(at(0, 3)).unwrap(); |
| 6425 | 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 | 6588 | assert_eq!(shown(&editor), ["Bef", "[middle.txt]", "ore", ""]); |
| 6428 | 6589 | // A file is no text position: the caret starts "ore", the second text. |
| 6429 | 6590 | assert_eq!(editor.selection().positions, at(1, 0).positions); |
| ... | ... | @@ -6433,7 +6594,9 @@ mod tests { |
| 6433 | 6594 | if paragraphs.iter().any(|p| matches!(&p.content, ParagraphContent::Attachment(a) if *a == middle))))); |
| 6434 | 6595 | |
| 6435 | 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 | 6600 | assert_eq!( |
| 6438 | 6601 | shown(&editor), |
| 6439 | 6602 | ["Bef", "[middle.txt]", "ore", "[empty.bin]", ""] |
| ... | ... | @@ -6447,7 +6610,7 @@ mod tests { |
| 6447 | 6610 | } |
| 6448 | 6611 | |
| 6449 | 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 | 6614 | use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported}; |
| 6452 | 6615 | let mut engine = TextEngine::default(); |
| 6453 | 6616 | let outline = |engine: &mut TextEngine| { |
| ... | ... | @@ -6481,8 +6644,10 @@ mod tests { |
| 6481 | 6644 | id: onestore::page::text::new_id().unwrap(), |
| 6482 | 6645 | layout: Default::default(), |
| 6483 | 6646 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), |
| 6647 | display: None, | |
| 6484 | 6648 | alt: None, |
| 6485 | 6649 | background: false, |
| 6650 | printout: None, | |
| 6486 | 6651 | }); |
| 6487 | 6652 | let (mixed_id, picture_id) = (mixed.id, picture_only.id); |
| 6488 | 6653 | let editor = CanvasEditor::from_page( |
| ... | ... | @@ -6512,11 +6677,15 @@ mod tests { |
| 6512 | 6677 | )); |
| 6513 | 6678 | assert_eq!(placeholder.rect[2] - placeholder.rect[0], 160.0); |
| 6514 | 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 | 6686 | object, |
| 6517 | page::Content::Outline { source, .. } if source.id == picture_id | |
| 6687 | 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 | } |
| 6521 | 6690 | |
| 6522 | 6691 | #[test] |
| ... | ... | @@ -6560,8 +6729,10 @@ mod tests { |
| 6560 | 6729 | ..Default::default() |
| 6561 | 6730 | }, |
| 6562 | 6731 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), |
| 6732 | display: None, | |
| 6563 | 6733 | alt: None, |
| 6564 | 6734 | background, |
| 6735 | printout: None, | |
| 6565 | 6736 | }); |
| 6566 | 6737 | let image_ids = images.each_ref().map(|image| image.id); |
| 6567 | 6738 | let image_bytes = images |
| ... | ... | @@ -9136,8 +9307,10 @@ mod tests { |
| 9136 | 9307 | }, |
| 9137 | 9308 | size: None, |
| 9138 | 9309 | bytes: None, |
| 9310 | display: None, | |
| 9139 | 9311 | alt: None, |
| 9140 | 9312 | background: false, |
| 9313 | printout: None, | |
| 9141 | 9314 | }); |
| 9142 | 9315 | if objects { |
| 9143 | 9316 | let selection = [TextPosition { |
crates/canvas/src/editor/equation.rs+45-21| ... | ... | @@ -119,28 +119,52 @@ impl CanvasEditor { |
| 119 | 119 | self.pending = Some((outline.id, start, format)); |
| 120 | 120 | return Ok(()); |
| 121 | 121 | } |
| 122 | if start.paragraph != end.paragraph { | |
| 123 | return Err(EditError::UnsupportedContent.into()); | |
| 122 | // Across paragraphs each paragraph's part becomes an equation of its own, as in | |
| 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 = outline | |
| 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(()) | |
| 167 | result | |
| 144 | 168 | } |
| 145 | 169 | |
| 146 | 170 | /// 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 | 174 | /// COLORREFs; none leaves the text's own. |
| 175 | 175 | pub color: Option<u32>, |
| 176 | 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 | } |
| 178 | 182 | |
| 179 | 183 | /// 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 | 224 | // The highlighting tags also set black text. |
| 221 | 225 | color: highlight.map(|_| 0), |
| 222 | 226 | highlight, |
| 227 | art: None, | |
| 223 | 228 | }) |
| 224 | 229 | .collect() |
| 225 | 230 | } |
| 226 | 231 | |
| 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 | 240 | /// The definition OneNote stores for the tag at place `action_type` in the list. |
| 228 | 241 | pub fn definition(&self, action_type: u16) -> Definition { |
| 229 | 242 | Definition { |
| ... | ... | @@ -256,6 +269,7 @@ impl NoteTag { |
| 256 | 269 | shape: shape.unwrap_or(0), |
| 257 | 270 | color: *color, |
| 258 | 271 | highlight: *highlight, |
| 272 | art: None, | |
| 259 | 273 | }, |
| 260 | 274 | action_type.unwrap_or(0), |
| 261 | 275 | )) |
| ... | ... | @@ -905,7 +919,7 @@ impl CanvasEditor { |
| 905 | 919 | Formatting::Toggle(toggle) => state.toggles.contains(toggle), |
| 906 | 920 | Formatting::Bullets => state.bullets, |
| 907 | 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 | 923 | _ => false, |
| 910 | 924 | }; |
| 911 | 925 | let mut outlines = Vec::new(); |
| ... | ... | @@ -939,9 +953,10 @@ impl CanvasEditor { |
| 939 | 953 | self.focus_outline(outline)?; |
| 940 | 954 | let outline = self.active_outline(); |
| 941 | 955 | let document = &outline.document; |
| 942 | let paragraph = leaves(document.nodes(), None) | |
| 943 | .position(|(_, _, node)| node.id == id) | |
| 944 | .ok_or(EditError::InvalidRange)?; | |
| 956 | let Some(paragraph) = leaves(document.nodes(), None).position(|(_, _, node)| node.id == id) | |
| 957 | else { | |
| 958 | return self.click_block_check(engine, id); | |
| 959 | }; | |
| 945 | 960 | let at = TextPosition { |
| 946 | 961 | paragraph, |
| 947 | 962 | offset: 0, |
| ... | ... | @@ -965,6 +980,53 @@ impl CanvasEditor { |
| 965 | 980 | ) |
| 966 | 981 | } |
| 967 | 982 | |
| 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 | 1030 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are |
| 969 | 1031 | /// checked; OneNote keeps a cleared box's completion time as zero. |
| 970 | 1032 | fn check( |
| ... | ... | @@ -1976,6 +2038,7 @@ mod tests { |
| 1976 | 2038 | shape: 61, |
| 1977 | 2039 | color: Some(0x0000_0080), |
| 1978 | 2040 | highlight: Some(0x00ff_cc00), |
| 2041 | art: None, | |
| 1979 | 2042 | }; |
| 1980 | 2043 | editor |
| 1981 | 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 | 6 | use onestore::{ |
| 7 | 7 | ExGuid, |
| 8 | 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 | }; |
| 11 | 15 | |
| 12 | 16 | /// A pen or highlighter, as OneNote's pen gallery and Color & Thickness choose one. |
| ... | ... | @@ -47,8 +51,9 @@ impl Pen { |
| 47 | 51 | } |
| 48 | 52 | } |
| 49 | 53 | |
| 50 | /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored. | |
| 51 | pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> { | |
| 54 | /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored, its width | |
| 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 | 57 | let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0); |
| 53 | 58 | Ok(InkStroke { |
| 54 | 59 | id: new_id()?, |
| ... | ... | @@ -63,6 +68,7 @@ impl Pen { |
| 63 | 68 | transparency: self.highlighter.then_some(127), |
| 64 | 69 | pen_tip: self.highlighter.then_some(1), |
| 65 | 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 | 175 | format!("\u{fddf}HYPERLINK \"{address}\"") |
| 176 | 176 | } |
| 177 | 177 | |
| 178 | /// A range of a paragraph the Link dialog links, with the text it shows and the address it | |
| 179 | /// holds. | |
| 180 | type Linked = (usize, Range<u32>, String, String); | |
| 181 | ||
| 178 | 182 | impl CanvasEditor { |
| 179 | 183 | /// The link in the active outline at `position`, its ends included. |
| 180 | 184 | pub fn link_at(&self, position: TextPosition) -> Option<Link> { |
| ... | ... | @@ -234,12 +238,13 @@ impl CanvasEditor { |
| 234 | 238 | |
| 235 | 239 | /// What the Link dialog links, as OneNote 2010 picks it: the selection within one |
| 236 | 240 | /// 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. | |
| 238 | fn link_range(&self) -> Result<(usize, Range<u32>, String, String), EditError> { | |
| 241 | /// text it shows and the address it holds; none for a selection across paragraphs, which | |
| 242 | /// OneNote's Link leaves alone. | |
| 243 | fn link_range(&self) -> Result<Option<Linked>, EditError> { | |
| 239 | 244 | let [anchor, focus] = self.active_outline().selection.positions; |
| 240 | 245 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 241 | 246 | if start.paragraph != end.paragraph { |
| 242 | return Err(EditError::UnsupportedContent); | |
| 247 | return Ok(None); | |
| 243 | 248 | } |
| 244 | 249 | let text = self |
| 245 | 250 | .active_outline() |
| ... | ... | @@ -263,11 +268,16 @@ impl CanvasEditor { |
| 263 | 268 | .filter(|link| link.range().start <= range.start && range.end <= link.label.end) |
| 264 | 269 | .map(|link| link.target) |
| 265 | 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 | 278 | if let Some(link) = self.link_at(start) { |
| 269 | 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 | 282 | // The word at the caret, within plain visible text. |
| 273 | 283 | let byte = text.byte_offset(start.offset)?; |
| ... | ... | @@ -293,14 +303,22 @@ impl CanvasEditor { |
| 293 | 303 | .last() |
| 294 | 304 | .map_or(byte, |(at, c)| byte + at + c.len_utf8()); |
| 295 | 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 | } |
| 298 | 313 | |
| 299 | /// The text and address the Link dialog opens with. | |
| 300 | pub fn link_prefill(&self) -> (String, String) { | |
| 301 | self.link_range() | |
| 302 | .map(|(_, _, text, address)| (text, address)) | |
| 303 | .unwrap_or_default() | |
| 314 | /// The text and address the Link dialog opens with; none where it does not open, over a | |
| 315 | /// selection across paragraphs. | |
| 316 | pub fn link_prefill(&self) -> Option<(String, String)> { | |
| 317 | match self.link_range() { | |
| 318 | Ok(Some((_, _, text, address))) => Some((text, address)), | |
| 319 | Ok(None) => None, | |
| 320 | Err(_) => Some(Default::default()), | |
| 321 | } | |
| 304 | 322 | } |
| 305 | 323 | |
| 306 | 324 | /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the |
| ... | ... | @@ -314,7 +332,9 @@ impl CanvasEditor { |
| 314 | 332 | if address.trim().is_empty() { |
| 315 | 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 | 338 | let current = self |
| 319 | 339 | .active_outline() |
| 320 | 340 | .document |
| ... | ... | @@ -601,7 +621,7 @@ mod tests { |
| 601 | 621 | typed(&mut editor, engine, "Zplain words here"); |
| 602 | 622 | let paragraph = editor.selection().positions[0].paragraph; |
| 603 | 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 | 625 | editor.set_link(engine, "here", "example.net/x").unwrap(); |
| 606 | 626 | typed(&mut editor, engine, "Q"); |
| 607 | 627 | editor.enter(engine, false).unwrap(); |
| ... | ... | @@ -622,12 +642,15 @@ mod tests { |
| 622 | 642 | .into(), |
| 623 | 643 | ) |
| 624 | 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 | 649 | editor |
| 627 | 650 | .set_link(engine, "me please", "https://example.com/selected") |
| 628 | 651 | .unwrap(); |
| 629 | 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 | 654 | editor |
| 632 | 655 | .set_link(engine, "", "https://example.com/only") |
| 633 | 656 | .unwrap(); |
crates/canvas/src/editor/ops.rs+60-4| ... | ... | @@ -15,6 +15,44 @@ fn stale(_: EditError) -> onestore::Error { |
| 15 | 15 | refused("The editor lost track of the stored page") |
| 16 | 16 | } |
| 17 | 17 | |
| 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. | |
| 20 | fn 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 | 56 | /// Definitions a paragraph names: its lists, style and note tags. |
| 19 | 57 | pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + '_ { |
| 20 | 58 | let content_tags = match &node.content { |
| ... | ... | @@ -266,12 +304,30 @@ impl CanvasEditor { |
| 266 | 304 | taken |
| 267 | 305 | } |
| 268 | 306 | |
| 307 | /// Queues `lowered`, storing first the provisional paragraph of an outline an op reaches. | |
| 269 | 308 | pub(super) fn record(&mut self, lowered: Lowered) { |
| 270 | if let Ok(ops) = &mut self.ops { | |
| 271 | match lowered { | |
| 272 | Ok(mut lowered) => ops.append(&mut lowered), | |
| 273 | Err(error) => self.ops = Err(error), | |
| 309 | let Ok(ops) = &mut self.ops else { | |
| 310 | return; | |
| 311 | }; | |
| 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 | } |
| 277 | 333 |
crates/canvas/src/editor/page.rs+36-5| ... | ... | @@ -5,7 +5,7 @@ use crate::{ |
| 5 | 5 | outline::{Arrange, OutlineLayout}, |
| 6 | 6 | }; |
| 7 | 7 | use onestore::page::text::Paragraph; |
| 8 | use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject}; | |
| 8 | use onestore::page::{Attachment, Image, Ink, Outline, Page, PageObject, PageParagraph}; | |
| 9 | 9 | use std::collections::BTreeMap; |
| 10 | 10 | |
| 11 | 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 | 21 | pub outlines: Vec<TextOutline>, |
| 22 | 22 | pub date: Option<PageDate>, |
| 23 | 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 | } |
| 25 | 27 | |
| 26 | 28 | pub(crate) enum Content { |
| ... | ... | @@ -184,6 +186,7 @@ pub(crate) fn build( |
| 184 | 186 | let mut outlines = Vec::new(); |
| 185 | 187 | let mut areas = Vec::new(); |
| 186 | 188 | let mut date = None; |
| 189 | let mut provisional = BTreeMap::new(); | |
| 187 | 190 | for object in std::mem::take(&mut page.objects) { |
| 188 | 191 | match &object { |
| 189 | 192 | PageObject::Outline(outline) => { |
| ... | ... | @@ -202,7 +205,28 @@ pub(crate) fn build( |
| 202 | 205 | ) |
| 203 | 206 | }; |
| 204 | 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 | 230 | Ok(outline) => { |
| 207 | 231 | objects.push(Content::Editable(outline.id)); |
| 208 | 232 | outlines.push(outline); |
| ... | ... | @@ -210,7 +234,9 @@ pub(crate) fn build( |
| 210 | 234 | } |
| 211 | 235 | Err(error) if !unsupported(&error) => return Err(error), |
| 212 | 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 | 242 | match outline.layout(engine, &page.definitions) { |
| ... | ... | @@ -396,6 +422,7 @@ pub(crate) fn build( |
| 396 | 422 | outlines, |
| 397 | 423 | date, |
| 398 | 424 | areas, |
| 425 | provisional, | |
| 399 | 426 | }) |
| 400 | 427 | } |
| 401 | 428 | |
| ... | ... | @@ -415,11 +442,15 @@ fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> { |
| 415 | 442 | ink.strokes |
| 416 | 443 | .iter() |
| 417 | 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 | 446 | stroke |
| 420 | 447 | .points |
| 421 | 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 | 455 | .chain(ink.groups.iter().filter_map(stroke_bounds)) |
| 425 | 456 | .reduce(|a, b| { |
crates/canvas/src/editor/recording.rs+6-1| ... | ... | @@ -88,8 +88,13 @@ impl CanvasEditor { |
| 88 | 88 | if self.recording.is_some() { |
| 89 | 89 | return Err(EditError::InvalidRange.into()); |
| 90 | 90 | } |
| 91 | let id = onestore::page::text::new_guid()?; | |
| 92 | 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 | 98 | let [anchor, focus] = self.selection().positions; |
| 94 | 99 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 95 | 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 | 35 | None |
| 36 | 36 | } |
| 37 | 37 | |
| 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. | |
| 38 | 40 | fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> { |
| 39 | let (_, _, paragraph) = leaves(&source.paragraphs, None) | |
| 40 | .next() | |
| 41 | .ok_or(EditError::UnsupportedContent)?; | |
| 42 | let format = paragraph.text().unwrap().text.format_at(0)?; | |
| 43 | let mut node = crate::document::node( | |
| 44 | Paragraph::new(String::new(), format.clone()), | |
| 45 | paragraph.format.clone(), | |
| 46 | )?; | |
| 47 | node.style = paragraph.style; | |
| 41 | let node = match leaves(&source.paragraphs, None).next() { | |
| 42 | Some((_, _, paragraph)) => { | |
| 43 | let format = paragraph.text().unwrap().text.format_at(0)?; | |
| 44 | let mut node = crate::document::node( | |
| 45 | Paragraph::new(String::new(), format.clone()), | |
| 46 | paragraph.format.clone(), | |
| 47 | )?; | |
| 48 | 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 | 56 | Ok(TableCell { |
| 49 | 57 | id: new_id()?, |
| 50 | 58 | layout: Default::default(), |
| ... | ... | @@ -377,6 +385,16 @@ impl CanvasEditor { |
| 377 | 385 | ) |
| 378 | 386 | } |
| 379 | 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 | 398 | if soft { |
| 381 | 399 | return self.insert(engine, "\u{000b}"); |
| 382 | 400 | } |
crates/canvas/src/gpu/mod.rs+2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | pub mod page; |
| 2 | 2 | #[cfg(test)] |
| 3 | 3 | mod profile; |
| 4 | mod tag_art; | |
| 5 | pub use tag_art::{TagArt, art_sources, import as import_tag_art}; | |
| 4 | 6 | |
| 5 | 7 | use crate::{ |
| 6 | 8 | layout::{TextBrush, TextLayout}, |
crates/canvas/src/gpu/page.rs+161-48| ... | ... | @@ -98,7 +98,8 @@ fn outline_origin( |
| 98 | 98 | |
| 99 | 99 | /// Draws `ink` moved by `offset`, strokes without a colour in the paper's `automatic` ink. A |
| 100 | 100 | /// highlighter's rectangular tip sweeps a band as wide as the tip reaches across each |
| 101 | /// segment, its colour multiplying what lies beneath as OneNote's MaskPen does. | |
| 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. | |
| 102 | 103 | pub(crate) fn append_ink( |
| 103 | 104 | ink: &onestore::page::Ink, |
| 104 | 105 | offset: [f32; 2], |
| ... | ... | @@ -112,12 +113,21 @@ pub(crate) fn append_ink( |
| 112 | 113 | color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; |
| 113 | 114 | } |
| 114 | 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 | 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 | 127 | return Primitive::Segment { |
| 118 | 128 | from, |
| 119 | 129 | to, |
| 120 | width: stroke.width.max(stroke.height), | |
| 130 | width: size * (t0 + t1) / 2.0, | |
| 121 | 131 | round, |
| 122 | 132 | color, |
| 123 | 133 | }; |
| ... | ... | @@ -132,28 +142,30 @@ pub(crate) fn append_ink( |
| 132 | 142 | Primitive::Highlight { |
| 133 | 143 | from, |
| 134 | 144 | to, |
| 135 | width: stroke.width * across + stroke.height * along, | |
| 145 | width: (stroke.width * across + stroke.height * along) * (t0 + t1) / 2.0, | |
| 136 | 146 | color, |
| 137 | 147 | } |
| 138 | 148 | }; |
| 139 | 149 | let mut points = stroke |
| 140 | 150 | .points |
| 141 | 151 | .iter() |
| 142 | .map(|[x, y]| [x + offset[0], y + offset[1]]); | |
| 143 | let Some(mut from) = points.next() else { | |
| 152 | .enumerate() | |
| 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 | 155 | continue; |
| 145 | 156 | }; |
| 146 | // Samples along a straight run make one segment, since each joint paints its overlap | |
| 147 | // twice. | |
| 148 | let mut end = from; | |
| 149 | for to in points { | |
| 150 | if !between(from, end, to) { | |
| 151 | primitives.push(segment(from, end)); | |
| 152 | from = end; | |
| 157 | // Samples along a straight run of one width make one segment, since each joint paints | |
| 158 | // its overlap twice. | |
| 159 | let (mut end, mut end_thick) = (from, thick); | |
| 160 | for (to, to_thick) in points { | |
| 161 | let same = end == from || (to_thick == end_thick && end_thick == thick); | |
| 162 | if !(same && between(from, end, to)) { | |
| 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 | 170 | for group in &ink.groups { |
| 159 | 171 | append_ink(group, offset, automatic, primitives); |
| ... | ... | @@ -326,21 +338,18 @@ impl PageScene { |
| 326 | 338 | } |
| 327 | 339 | |
| 328 | 340 | /// 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 | 343 | fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> { |
| 331 | fn nested<'a>( | |
| 332 | nodes: &'a [onestore::page::PageParagraph], | |
| 333 | payloads: &mut Vec<(onestore::ExGuid, Option<&'a Arc<[u8]>>)>, | |
| 334 | ) { | |
| 344 | type Payloads<'a> = Vec<(onestore::ExGuid, [Option<&'a Arc<[u8]>>; 2])>; | |
| 345 | fn nested<'a>(nodes: &'a [onestore::page::PageParagraph], payloads: &mut Payloads<'a>) { | |
| 335 | 346 | for node in nodes { |
| 336 | 347 | match &node.content { |
| 337 | 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 | 351 | onestore::page::ParagraphContent::Attachment(file) => { |
| 341 | if let Some(icon) = file.preview.as_ref() { | |
| 342 | payloads.push((file.id, Some(icon))) | |
| 343 | } | |
| 352 | payloads.push((file.id, [file.preview.as_ref(), None])) | |
| 344 | 353 | } |
| 345 | 354 | onestore::page::ParagraphContent::Table(table) => { |
| 346 | 355 | for cell in table.rows.iter().flat_map(|row| &row.cells) { |
| ... | ... | @@ -361,13 +370,11 @@ impl PageScene { |
| 361 | 370 | { |
| 362 | 371 | backgrounds.insert(source.id, art); |
| 363 | 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 | 376 | Content::File { source, .. } => { |
| 368 | if let Some(icon) = source.preview.as_ref() { | |
| 369 | payloads.push((source.id, Some(icon))) | |
| 370 | } | |
| 377 | payloads.push((source.id, [source.preview.as_ref(), None])) | |
| 371 | 378 | } |
| 372 | 379 | Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), |
| 373 | 380 | Content::Editable(id) => { |
| ... | ... | @@ -382,7 +389,7 @@ impl PageScene { |
| 382 | 389 | } |
| 383 | 390 | let mut pictures = std::collections::BTreeMap::new(); |
| 384 | 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 | 393 | continue; |
| 387 | 394 | }; |
| 388 | 395 | if pictures.insert(id, picture).is_some() { |
| ... | ... | @@ -772,6 +779,7 @@ impl PageScene { |
| 772 | 779 | } |
| 773 | 780 | |
| 774 | 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 | 783 | pub fn append_primitives_with<'a, E: From<SceneError>>( |
| 776 | 784 | &'a self, |
| 777 | 785 | primitives: &mut Vec<Primitive<'a>>, |
| ... | ... | @@ -904,23 +912,31 @@ impl PageScene { |
| 904 | 912 | let Some(image) = image else { |
| 905 | 913 | continue; |
| 906 | 914 | }; |
| 907 | primitives.push(Primitive::Image { | |
| 908 | image, | |
| 909 | rect: match moving { | |
| 910 | Some((id, [x0, y0, x1, y1])) if id == source.id => [ | |
| 911 | x0 + offset[0], | |
| 912 | y0 + offset[1], | |
| 913 | x1 + offset[0], | |
| 914 | y1 + offset[1], | |
| 915 | ], | |
| 916 | _ => [ | |
| 917 | object_origin[0], | |
| 918 | object_origin[1], | |
| 919 | object_origin[0] + width, | |
| 920 | object_origin[1] + height, | |
| 921 | ], | |
| 922 | }, | |
| 923 | }) | |
| 915 | let rect = match moving { | |
| 916 | Some((id, [x0, y0, x1, y1])) if id == source.id => [ | |
| 917 | x0 + offset[0], | |
| 918 | y0 + offset[1], | |
| 919 | x1 + offset[0], | |
| 920 | y1 + offset[1], | |
| 921 | ], | |
| 922 | _ => [ | |
| 923 | object_origin[0], | |
| 924 | object_origin[1], | |
| 925 | object_origin[0] + width, | |
| 926 | object_origin[1] + height, | |
| 927 | ], | |
| 928 | }; | |
| 929 | primitives.push(Primitive::Image { image, rect }); | |
| 930 | // OneNote frames a printout's page with a one-pixel grey line. | |
| 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 | 941 | Content::Outline { .. } | Content::Date { .. } => { |
| 926 | 942 | let outline = match content { |
| ... | ... | @@ -931,6 +947,8 @@ impl PageScene { |
| 931 | 947 | .layout(), |
| 932 | 948 | }; |
| 933 | 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 | 952 | let everything = [f32::NEG_INFINITY, f32::INFINITY]; |
| 935 | 953 | outline.append_background_primitives( |
| 936 | 954 | primitives, |
| ... | ... | @@ -945,6 +963,7 @@ impl PageScene { |
| 945 | 963 | paragraph, |
| 946 | 964 | object_origin, |
| 947 | 965 | paper.ink, |
| 966 | &art, | |
| 948 | 967 | primitives, |
| 949 | 968 | ); |
| 950 | 969 | } |
| ... | ... | @@ -1040,13 +1059,14 @@ impl crate::outline::OutlineLayout { |
| 1040 | 1059 | } |
| 1041 | 1060 | |
| 1042 | 1061 | /// 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 | 1063 | pub fn append_paragraph_primitives<'a>( |
| 1045 | 1064 | &'a self, |
| 1046 | 1065 | index: usize, |
| 1047 | 1066 | paragraph: &'a crate::outline::ParagraphLayout, |
| 1048 | 1067 | origin: [f32; 2], |
| 1049 | 1068 | ink: [f32; 4], |
| 1069 | art: &super::TagArt, | |
| 1050 | 1070 | primitives: &mut Vec<Primitive<'a>>, |
| 1051 | 1071 | ) { |
| 1052 | 1072 | let [x, y] = [ |
| ... | ... | @@ -1117,7 +1137,7 @@ impl crate::outline::OutlineLayout { |
| 1117 | 1137 | } |
| 1118 | 1138 | for tag in &paragraph.tags { |
| 1119 | 1139 | primitives.push(Primitive::Icon { |
| 1120 | sources: super::tag_sources(tag.icon), | |
| 1140 | sources: art.sources(tag), | |
| 1121 | 1141 | origin: [ |
| 1122 | 1142 | origin[0] + self.tag_column_offset() + tag.origin[0], |
| 1123 | 1143 | y + tag.origin[1], |
| ... | ... | @@ -1129,6 +1149,33 @@ impl crate::outline::OutlineLayout { |
| 1129 | 1149 | } |
| 1130 | 1150 | } |
| 1131 | 1151 | |
| 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 | 1179 | pub fn append_table_primitives( |
| 1133 | 1180 | &self, |
| 1134 | 1181 | primitives: &mut Vec<Primitive<'_>>, |
| ... | ... | @@ -1577,6 +1624,7 @@ mod tests { |
| 1577 | 1624 | size: None, |
| 1578 | 1625 | id: onestore::page::text::new_id().unwrap(), |
| 1579 | 1626 | bytes: Some(image_bytes.clone()), |
| 1627 | display: None, | |
| 1580 | 1628 | layout: Layout { |
| 1581 | 1629 | x: Some(x), |
| 1582 | 1630 | y: Some(y), |
| ... | ... | @@ -1586,6 +1634,7 @@ mod tests { |
| 1586 | 1634 | }, |
| 1587 | 1635 | alt: None, |
| 1588 | 1636 | background, |
| 1637 | printout: None, | |
| 1589 | 1638 | })); |
| 1590 | 1639 | } |
| 1591 | 1640 | let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| ... | ... | @@ -1944,8 +1993,10 @@ mod tests { |
| 1944 | 1993 | ..Default::default() |
| 1945 | 1994 | }, |
| 1946 | 1995 | bytes: None, |
| 1996 | display: None, | |
| 1947 | 1997 | alt: Some("missing diagram".into()), |
| 1948 | 1998 | background: false, |
| 1999 | printout: None, | |
| 1949 | 2000 | }), |
| 1950 | 2001 | PageObject::Unsupported(unknown.clone()), |
| 1951 | 2002 | ], |
| ... | ... | @@ -2112,8 +2163,10 @@ mod tests { |
| 2112 | 2163 | ..Default::default() |
| 2113 | 2164 | }, |
| 2114 | 2165 | bytes: Some(Arc::from(bytes.clone())), |
| 2166 | display: None, | |
| 2115 | 2167 | alt: None, |
| 2116 | 2168 | background: false, |
| 2169 | printout: None, | |
| 2117 | 2170 | }), |
| 2118 | 2171 | outline(50.0, "last"), |
| 2119 | 2172 | ], |
| ... | ... | @@ -2254,6 +2307,7 @@ mod tests { |
| 2254 | 2307 | transparency: Some(51), |
| 2255 | 2308 | pen_tip: None, |
| 2256 | 2309 | raster_operation: None, |
| 2310 | pressure: Vec::new(), | |
| 2257 | 2311 | }; |
| 2258 | 2312 | let ink = Ink { |
| 2259 | 2313 | id: ExGuid::default(), |
| ... | ... | @@ -2317,6 +2371,59 @@ mod tests { |
| 2317 | 2371 | assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]); |
| 2318 | 2372 | } |
| 2319 | 2373 | |
| 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 | 2427 | #[test] |
| 2321 | 2428 | fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { |
| 2322 | 2429 | let mut encoded = Vec::new(); |
| ... | ... | @@ -2349,8 +2456,10 @@ mod tests { |
| 2349 | 2456 | ..Default::default() |
| 2350 | 2457 | }, |
| 2351 | 2458 | bytes: Some(Arc::from(encoded.clone())), |
| 2459 | display: None, | |
| 2352 | 2460 | alt: None, |
| 2353 | 2461 | background: false, |
| 2462 | printout: None, | |
| 2354 | 2463 | })], |
| 2355 | 2464 | }; |
| 2356 | 2465 | let mut engine = TextEngine::default(); |
| ... | ... | @@ -2412,8 +2521,10 @@ mod tests { |
| 2412 | 2521 | ..Default::default() |
| 2413 | 2522 | }, |
| 2414 | 2523 | bytes: Some(Arc::from(bytes.clone())), |
| 2524 | display: None, | |
| 2415 | 2525 | alt: None, |
| 2416 | 2526 | background, |
| 2527 | printout: None, | |
| 2417 | 2528 | }; |
| 2418 | 2529 | let [background, picture] = [image(0.0, true), image(50.0, false)]; |
| 2419 | 2530 | let id = picture.id; |
| ... | ... | @@ -2481,8 +2592,10 @@ mod tests { |
| 2481 | 2592 | ..Default::default() |
| 2482 | 2593 | }, |
| 2483 | 2594 | bytes: Some(Arc::clone(&bytes)), |
| 2595 | display: None, | |
| 2484 | 2596 | alt: None, |
| 2485 | 2597 | background: false, |
| 2598 | printout: None, | |
| 2486 | 2599 | }) |
| 2487 | 2600 | .collect(); |
| 2488 | 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 | 753 | }, |
| 754 | 754 | size: None, |
| 755 | 755 | bytes: Some(Arc::from(bytes)), |
| 756 | display: None, | |
| 756 | 757 | alt: None, |
| 757 | 758 | background: true, |
| 759 | printout: None, | |
| 758 | 760 | } |
| 759 | 761 | } |
| 760 | 762 |
crates/canvas/src/gpu/profile.rs+2| ... | ... | @@ -243,8 +243,10 @@ fn frame_cost() { |
| 243 | 243 | }, |
| 244 | 244 | size: None, |
| 245 | 245 | bytes: None, |
| 246 | display: None, | |
| 246 | 247 | alt: None, |
| 247 | 248 | background: false, |
| 249 | printout: None, | |
| 248 | 250 | }); |
| 249 | 251 | let cell = || TableCell { |
| 250 | 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 | ||
| 5 | use super::{CHECKMARK, tag_sources}; | |
| 6 | use crate::outline::{ParagraphTag, TagIcon}; | |
| 7 | use resvg::{tiny_skia, usvg}; | |
| 8 | use 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. | |
| 11 | const SIDE: u32 = 128; | |
| 12 | ||
| 13 | /// Icon sources drawing a tag's art, unchecked and checked. | |
| 14 | type 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. | |
| 18 | pub 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. | |
| 29 | pub 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. | |
| 51 | fn 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. | |
| 62 | fn 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. | |
| 70 | fn 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. | |
| 97 | fn 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 | ||
| 105 | fn 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)] | |
| 119 | pub struct TagArt(Vec<Mapped>); | |
| 120 | ||
| 121 | #[derive(Clone)] | |
| 122 | struct Mapped { | |
| 123 | name: String, | |
| 124 | shape: u16, | |
| 125 | art: String, | |
| 126 | sources: Sources, | |
| 127 | } | |
| 128 | ||
| 129 | impl 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)] | |
| 167 | mod 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 | 118 | editor: &CanvasEditor, |
| 119 | 119 | scene: Option<&(PageScene, [f32; 2])>, |
| 120 | 120 | viewport: Viewport, |
| 121 | title: &str, | |
| 121 | label: &str, | |
| 122 | 122 | preview: Option<(ExGuid, [f32; 2])>, |
| 123 | 123 | read_only_focus: Option<usize>, |
| 124 | 124 | ) -> Result<TreeUpdate, EditError> { |
| ... | ... | @@ -331,8 +331,8 @@ impl Accessibility { |
| 331 | 331 | f64::from(viewport.origin[1]), |
| 332 | 332 | ])); |
| 333 | 333 | page.set_children(children); |
| 334 | let mut root = Node::new(Role::Window); | |
| 335 | root.set_label(title); | |
| 334 | let mut root = Node::new(Role::Group); | |
| 335 | root.set_label(label); | |
| 336 | 336 | root.set_children(vec![PAGE]); |
| 337 | 337 | root.set_bounds(Rect::new( |
| 338 | 338 | 0.0, |
crates/canvas/src/interaction/ink.rs+24-9| ... | ... | @@ -47,10 +47,13 @@ pub(super) struct State { |
| 47 | 47 | gesture: Option<Gesture>, |
| 48 | 48 | /// Drawings picked by the lasso or a click. |
| 49 | 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 | } |
| 51 | 53 | |
| 52 | 54 | enum 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 | 57 | Shape([f32; 2]), |
| 55 | 58 | /// `mark` counts the ops waiting when the sweep began. |
| 56 | 59 | Erase { |
| ... | ... | @@ -80,6 +83,12 @@ impl PageView { |
| 80 | 83 | Response::redraw() |
| 81 | 84 | } |
| 82 | 85 | |
| 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 | 92 | /// Drawings the lasso or a click picked. |
| 84 | 93 | pub fn ink_selection(&self) -> &[ExGuid] { |
| 85 | 94 | &self.ink.selection |
| ... | ... | @@ -154,7 +163,7 @@ impl PageView { |
| 154 | 163 | self.ink.selection = vec![id]; |
| 155 | 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 | 167 | Tool::Shape(..) => Gesture::Shape(self.grid(point)), |
| 159 | 168 | Tool::Eraser => { |
| 160 | 169 | let mark = self.editor.pending_ops(); |
| ... | ... | @@ -185,12 +194,18 @@ impl PageView { |
| 185 | 194 | |
| 186 | 195 | /// The pointer moved to page point `point` during a gesture. |
| 187 | 196 | pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> { |
| 197 | let pressure = self.ink.pressure; | |
| 188 | 198 | match &mut self.ink.gesture { |
| 189 | Some(Gesture::Stroke(points) | Gesture::Lasso(points)) => { | |
| 199 | Some(Gesture::Stroke(points, _) | Gesture::Lasso(points)) => { | |
| 190 | 200 | let last = points[points.len() - 1]; |
| 191 | 201 | // Samples closer than a HIMETRIC unit store as one point. |
| 192 | 202 | if (point[0] - last[0]).hypot(point[1] - last[1]) >= 72.0 / 2540.0 { |
| 193 | 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 | 210 | Ok(Response::redraw()) |
| 196 | 211 | } |
| ... | ... | @@ -222,14 +237,14 @@ impl PageView { |
| 222 | 237 | return Ok(Response::default()); |
| 223 | 238 | }; |
| 224 | 239 | match gesture { |
| 225 | Gesture::Stroke(points) => { | |
| 240 | Gesture::Stroke(points, pressure) => { | |
| 226 | 241 | let Tool::Pen(pen) = self.ink.tool else { |
| 227 | 242 | return Ok(Response::default()); |
| 228 | 243 | }; |
| 229 | 244 | self.editor.draw(Ink { |
| 230 | 245 | id: new_id()?, |
| 231 | 246 | layout: Default::default(), |
| 232 | strokes: vec![pen.stroke(&points)?], | |
| 247 | strokes: vec![pen.stroke(&points, &pressure)?], | |
| 233 | 248 | groups: Vec::new(), |
| 234 | 249 | shape: None, |
| 235 | 250 | })?; |
| ... | ... | @@ -243,7 +258,7 @@ impl PageView { |
| 243 | 258 | return Ok(Response::redraw()); |
| 244 | 259 | } |
| 245 | 260 | self.editor |
| 246 | .draw(Ink::drawn(kind, from, to, &pen.stroke(&[])?)?)?; | |
| 261 | .draw(Ink::drawn(kind, from, to, &pen.stroke(&[], &[])?)?)?; | |
| 247 | 262 | } |
| 248 | 263 | Gesture::Erase { .. } => return Ok(Response::redraw()), |
| 249 | 264 | Gesture::Lasso(mut points) => { |
| ... | ... | @@ -312,14 +327,14 @@ impl PageView { |
| 312 | 327 | crate::gpu::page::append_ink(&ink, [0.0; 2], automatic, primitives) |
| 313 | 328 | }; |
| 314 | 329 | match (&self.ink.gesture, self.ink.tool) { |
| 315 | (Some(Gesture::Stroke(points)), Tool::Pen(pen)) => { | |
| 316 | if let Ok(stroke) = pen.stroke(points) { | |
| 330 | (Some(Gesture::Stroke(points, pressure)), Tool::Pen(pen)) => { | |
| 331 | if let Ok(stroke) = pen.stroke(points, pressure) { | |
| 317 | 332 | preview(ink(vec![stroke]), primitives); |
| 318 | 333 | } |
| 319 | 334 | } |
| 320 | 335 | (Some(Gesture::Shape(from)), Tool::Shape(kind, pen)) => { |
| 321 | 336 | let to = self.grid(point); |
| 322 | if let Ok(pen) = pen.stroke(&[]) | |
| 337 | if let Ok(pen) = pen.stroke(&[], &[]) | |
| 323 | 338 | && let Ok(shape) = Ink::drawn(kind, *from, to, &pen) |
| 324 | 339 | { |
| 325 | 340 | preview(shape, primitives); |
crates/canvas/src/interaction/mod.rs+350-47| ... | ... | @@ -84,6 +84,21 @@ pub struct Context { |
| 84 | 84 | pub paragraph: Option<onestore::ExGuid>, |
| 85 | 85 | /// The file the press selected, which Open and Save As take. |
| 86 | 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)] | |
| 93 | pub 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 | } |
| 88 | 103 | |
| 89 | 104 | /// What an event did: `changed` means the page or selection changed (the host saves, |
| ... | ... | @@ -136,6 +151,8 @@ pub struct TextColors { |
| 136 | 151 | struct Paint<'a> { |
| 137 | 152 | caret: f32, |
| 138 | 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 | 156 | pixel: f32, |
| 140 | 157 | colors: TextColors, |
| 141 | 158 | /// The document's top and bottom the view shows; paragraphs wholly outside it are |
| ... | ... | @@ -147,6 +164,8 @@ struct Paint<'a> { |
| 147 | 164 | found: &'a [crate::search::PageMatch], |
| 148 | 165 | /// The note See Playback highlights. |
| 149 | 166 | played: Option<&'a crate::search::PageMatch>, |
| 167 | spelling: Option<&'a crate::spelling::Spelling>, | |
| 168 | tag_art: &'a crate::gpu::TagArt, | |
| 150 | 169 | } |
| 151 | 170 | |
| 152 | 171 | enum Drag { |
| ... | ... | @@ -242,7 +261,11 @@ pub struct PageView { |
| 242 | 261 | /// Matches of the search shown on the page, marked under their text. |
| 243 | 262 | pub found: Vec<crate::search::PageMatch>, |
| 244 | 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 | 269 | /// The caret's opacity in its blink. |
| 247 | 270 | caret: f32, |
| 248 | 271 | /// When the caret last moved, which restarts its blink. |
| ... | ... | @@ -305,6 +328,8 @@ impl PageView { |
| 305 | 328 | host_viewport: false, |
| 306 | 329 | found: Vec::new(), |
| 307 | 330 | played: None, |
| 331 | spelling: None, | |
| 332 | tag_art: Default::default(), | |
| 308 | 333 | caret: 1.0, |
| 309 | 334 | blink_from: Instant::now(), |
| 310 | 335 | ink: Default::default(), |
| ... | ... | @@ -537,17 +562,15 @@ impl PageView { |
| 537 | 562 | let shaped = outline.shaped(); |
| 538 | 563 | let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset(); |
| 539 | 564 | let top = offset[1] + outline.origin()[1]; |
| 540 | shaped.paragraphs.iter().find_map(|paragraph| { | |
| 541 | paragraph.tags.iter().find_map(|tag| { | |
| 542 | let x0 = left + tag.origin[0]; | |
| 543 | let y0 = top + paragraph.origin[1] + tag.origin[1]; | |
| 544 | (matches!(tag.icon, crate::outline::TagIcon::Task { .. }) | |
| 545 | && (x0..=x0 + tag.size).contains(&x) | |
| 546 | && (y0..=y0 + tag.size).contains(&y)) | |
| 547 | .then(|| { | |
| 548 | let [x1, y1] = [x0 + tag.size, y0 + tag.size]; | |
| 549 | [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)] | |
| 550 | }) | |
| 565 | shaped.tags().find_map(|(_, origin, tag)| { | |
| 566 | let x0 = left + origin[0]; | |
| 567 | let y0 = top + origin[1]; | |
| 568 | (matches!(tag.icon, crate::outline::TagIcon::Task { .. }) | |
| 569 | && (x0..=x0 + tag.size).contains(&x) | |
| 570 | && (y0..=y0 + tag.size).contains(&y)) | |
| 571 | .then(|| { | |
| 572 | let [x1, y1] = [x0 + tag.size, y0 + tag.size]; | |
| 573 | [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)] | |
| 551 | 574 | }) |
| 552 | 575 | }) |
| 553 | 576 | }) |
| ... | ... | @@ -643,23 +666,33 @@ impl PageView { |
| 643 | 666 | rect.y1 += f64::from(origin[1]); |
| 644 | 667 | 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 | 679 | for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] |
| 648 | 680 | .into_iter() |
| 649 | 681 | .enumerate() |
| 650 | 682 | { |
| 651 | 683 | let size = f64::from(self.viewport.size[axis]); |
| 652 | 684 | let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); |
| 653 | let (outline_start, outline_end) = | |
| 654 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | |
| 655 | let outline_start = outline_start.min(start); | |
| 656 | let outline_end = outline_end.max(end); | |
| 657 | if self.object_focus.is_none() | |
| 658 | && (outline_end - outline_start) * f64::from(self.viewport.scale) | |
| 685 | if let Some(outline) = outline { | |
| 686 | let (outline_start, outline_end) = | |
| 687 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | |
| 688 | let outline_start = outline_start.min(start); | |
| 689 | let outline_end = outline_end.max(end); | |
| 690 | if (outline_end - outline_start) * f64::from(self.viewport.scale) | |
| 659 | 691 | <= size - margin * 2.0 |
| 660 | { | |
| 661 | start = outline_start; | |
| 662 | end = outline_end; | |
| 692 | { | |
| 693 | start = outline_start; | |
| 694 | end = outline_end; | |
| 695 | } | |
| 663 | 696 | } |
| 664 | 697 | let start = |
| 665 | 698 | start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); |
| ... | ... | @@ -673,7 +706,45 @@ impl PageView { |
| 673 | 706 | }; |
| 674 | 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 | } |
| 678 | 749 | |
| 679 | 750 | /// 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 | 939 | 0.0 |
| 869 | 940 | }, |
| 870 | 941 | scale: self.viewport.scale, |
| 942 | device_origin: self.viewport.origin, | |
| 871 | 943 | pixel: self.pixel(), |
| 872 | 944 | colors, |
| 873 | 945 | visible: [0.0, self.viewport.size[1] as f32] |
| ... | ... | @@ -875,6 +947,8 @@ impl PageView { |
| 875 | 947 | chrome: !self.touch || self.focused, |
| 876 | 948 | found: &self.found, |
| 877 | 949 | played: self.played.as_ref(), |
| 950 | spelling: self.spelling.as_ref(), | |
| 951 | tag_art: &self.tag_art, | |
| 878 | 952 | }, |
| 879 | 953 | )?); |
| 880 | 954 | if self.drag.is_none() |
| ... | ... | @@ -1429,12 +1503,125 @@ impl PageView { |
| 1429 | 1503 | .leaf(anchor.min(focus).paragraph) |
| 1430 | 1504 | .map(|(_, _, node)| node.id), |
| 1431 | 1505 | attachment: None, |
| 1506 | spelling: self.selected_correction(), | |
| 1432 | 1507 | }; |
| 1433 | 1508 | Ok(Some((self.changed()?, context))) |
| 1434 | 1509 | } |
| 1435 | 1510 | |
| 1436 | /// The text and address the Link dialog opens with for the selection. | |
| 1437 | pub fn link_prefill(&self) -> (String, String) { | |
| 1511 | /// The marked word at the caret, or wholly selected, with its corrections. | |
| 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 | 1625 | self.editor.link_prefill() |
| 1439 | 1626 | } |
| 1440 | 1627 | |
| ... | ... | @@ -1489,8 +1676,10 @@ impl PageView { |
| 1489 | 1676 | }, |
| 1490 | 1677 | size: Some(size), |
| 1491 | 1678 | bytes: Some(bytes.into()), |
| 1679 | display: None, | |
| 1492 | 1680 | alt: None, |
| 1493 | 1681 | background: false, |
| 1682 | printout: None, | |
| 1494 | 1683 | }; |
| 1495 | 1684 | self.editor.insert_picture(&mut self.engine, image)?; |
| 1496 | 1685 | self.edited() |
| ... | ... | @@ -1995,19 +2184,17 @@ fn page_hit( |
| 1995 | 2184 | } |
| 1996 | 2185 | let shaped = outline.shaped(); |
| 1997 | 2186 | let [left, top] = outline.origin(); |
| 1998 | let check = shaped.paragraphs.iter().find(|paragraph| { | |
| 1999 | paragraph.tags.iter().any(|tag| { | |
| 2000 | let tag_x = left + shaped.tag_column_offset() + tag.origin[0]; | |
| 2001 | let tag_y = top + paragraph.origin[1] + tag.origin[1]; | |
| 2002 | tag.icon.checkable() | |
| 2003 | && (tag_x..=tag_x + tag.size).contains(&x) | |
| 2004 | && (tag_y..=tag_y + tag.size).contains(&y) | |
| 2005 | }) | |
| 2187 | let check = shaped.tags().find(|(_, origin, tag)| { | |
| 2188 | let tag_x = left + shaped.tag_column_offset() + origin[0]; | |
| 2189 | let tag_y = top + origin[1]; | |
| 2190 | tag.icon.checkable() | |
| 2191 | && (tag_x..=tag_x + tag.size).contains(&x) | |
| 2192 | && (tag_y..=tag_y + tag.size).contains(&y) | |
| 2006 | 2193 | }); |
| 2007 | if let Some(paragraph) = check { | |
| 2194 | if let Some((paragraph, ..)) = check { | |
| 2008 | 2195 | return Some(Hit::Check { |
| 2009 | 2196 | outline: outline.id, |
| 2010 | paragraph: paragraph.id, | |
| 2197 | paragraph, | |
| 2011 | 2198 | }); |
| 2012 | 2199 | } |
| 2013 | 2200 | let text = outline.bounds(); |
| ... | ... | @@ -2435,14 +2622,13 @@ fn reach(outline: &TextOutline) -> [f32; 2] { |
| 2435 | 2622 | .iter() |
| 2436 | 2623 | .flat_map(|paragraph| { |
| 2437 | 2624 | let markers = paragraph.markers.iter().map(|(_, [x, _])| *x); |
| 2438 | let tags = paragraph | |
| 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) | |
| 2625 | std::iter::once(paragraph.origin[0] - 7.5).chain(markers) | |
| 2445 | 2626 | }) |
| 2627 | .chain( | |
| 2628 | shaped | |
| 2629 | .tags() | |
| 2630 | .map(|(_, origin, _)| origin[0] + column + 0.75), | |
| 2631 | ) | |
| 2446 | 2632 | .fold(-7.5, f32::min); |
| 2447 | 2633 | let [x, y] = outline.origin(); |
| 2448 | 2634 | [x + left, y - 6.0] |
| ... | ... | @@ -2492,11 +2678,8 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { |
| 2492 | 2678 | let left = shaped |
| 2493 | 2679 | .paragraphs |
| 2494 | 2680 | .iter() |
| 2495 | .flat_map(|paragraph| { | |
| 2496 | let tags = paragraph.tags.iter().map(|tag| tag.origin[0] + column); | |
| 2497 | let markers = paragraph.markers.iter().map(|(_, [x, _])| x + 7.5); | |
| 2498 | tags.chain(markers) | |
| 2499 | }) | |
| 2681 | .flat_map(|paragraph| paragraph.markers.iter().map(|(_, [x, _])| x + 7.5)) | |
| 2682 | .chain(shaped.tags().map(|(_, origin, _)| origin[0] + column)) | |
| 2500 | 2683 | .fold(0.0, f32::min); |
| 2501 | 2684 | ( |
| 2502 | 2685 | [ |
| ... | ... | @@ -2608,17 +2791,22 @@ fn append_outline<'a>( |
| 2608 | 2791 | let Paint { |
| 2609 | 2792 | caret, |
| 2610 | 2793 | scale, |
| 2794 | device_origin, | |
| 2611 | 2795 | pixel, |
| 2612 | 2796 | colors, |
| 2613 | 2797 | visible, |
| 2614 | 2798 | found, |
| 2615 | 2799 | played, |
| 2800 | spelling, | |
| 2616 | 2801 | .. |
| 2617 | 2802 | } = paint; |
| 2618 | 2803 | let rows = [visible[0] - y, visible[1] - y]; |
| 2619 | 2804 | outline |
| 2620 | 2805 | .shaped() |
| 2621 | 2806 | .append_table_primitives(primitives, origin, colors.paper); |
| 2807 | outline | |
| 2808 | .shaped() | |
| 2809 | .append_block_tag_primitives(primitives, origin, paint.tag_art); | |
| 2622 | 2810 | outline |
| 2623 | 2811 | .shaped() |
| 2624 | 2812 | .append_background_primitives(primitives, origin, rows, colors.paper); |
| ... | ... | @@ -2668,9 +2856,26 @@ fn append_outline<'a>( |
| 2668 | 2856 | paragraph, |
| 2669 | 2857 | origin, |
| 2670 | 2858 | colors.paper.ink, |
| 2859 | paint.tag_art, | |
| 2671 | 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 | 2879 | if let Some(editor) = editor { |
| 2675 | 2880 | for rect in editor.marked_rects()? { |
| 2676 | 2881 | primitives.push(Primitive::Rect { |
| ... | ... | @@ -2704,3 +2909,101 @@ fn append_outline<'a>( |
| 2704 | 2909 | } |
| 2705 | 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)] | |
| 2918 | fn 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. | |
| 2970 | fn 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 | 14 | paper: crate::gpu::Paper::WHITE, |
| 15 | 15 | }; |
| 16 | 16 | |
| 17 | static NO_ART: std::sync::LazyLock<crate::gpu::TagArt> = std::sync::LazyLock::new(Default::default); | |
| 18 | ||
| 17 | 19 | /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. |
| 18 | 20 | pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> { |
| 19 | 21 | Paint { |
| 20 | 22 | caret: f32::from(u8::from(show_caret)), |
| 21 | 23 | scale, |
| 24 | device_origin: [0.0; 2], | |
| 22 | 25 | pixel: display_scale / scale, |
| 23 | 26 | colors: COLORS, |
| 24 | 27 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 25 | 28 | chrome: true, |
| 26 | 29 | found: &[], |
| 27 | 30 | played: None, |
| 31 | spelling: None, | |
| 32 | tag_art: &NO_ART, | |
| 28 | 33 | } |
| 29 | 34 | } |
| 30 | 35 | |
| ... | ... | @@ -699,8 +704,10 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { |
| 699 | 704 | id, |
| 700 | 705 | layout: Default::default(), |
| 701 | 706 | bytes: None, |
| 707 | display: None, | |
| 702 | 708 | alt: None, |
| 703 | 709 | background: false, |
| 710 | printout: None, | |
| 704 | 711 | }); |
| 705 | 712 | source.paragraphs.insert(1, picture); |
| 706 | 713 | let editor = CanvasEditor::from_page( |
| ... | ... | @@ -885,12 +892,15 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { |
| 885 | 892 | Paint { |
| 886 | 893 | caret: 0.0, |
| 887 | 894 | scale: 1.0, |
| 895 | device_origin: [0.0; 2], | |
| 888 | 896 | pixel: 1.0, |
| 889 | 897 | colors: COLORS, |
| 890 | 898 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 891 | 899 | chrome: true, |
| 892 | 900 | found: &[], |
| 893 | 901 | played: None, |
| 902 | spelling: None, | |
| 903 | tag_art: &NO_ART, | |
| 894 | 904 | }, |
| 895 | 905 | &mut primitives, |
| 896 | 906 | ) |
| ... | ... | @@ -960,12 +970,15 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { |
| 960 | 970 | Paint { |
| 961 | 971 | caret: 0.0, |
| 962 | 972 | scale: 1.0, |
| 973 | device_origin: [0.0; 2], | |
| 963 | 974 | pixel: 1.0, |
| 964 | 975 | colors: COLORS, |
| 965 | 976 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 966 | 977 | chrome: true, |
| 967 | 978 | found: &[], |
| 968 | 979 | played: None, |
| 980 | spelling: None, | |
| 981 | tag_art: &NO_ART, | |
| 969 | 982 | }, |
| 970 | 983 | &mut primitives, |
| 971 | 984 | ) |
| ... | ... | @@ -1528,8 +1541,10 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1528 | 1541 | id, |
| 1529 | 1542 | layout: Default::default(), |
| 1530 | 1543 | bytes: None, |
| 1544 | display: None, | |
| 1531 | 1545 | alt: None, |
| 1532 | 1546 | background: false, |
| 1547 | printout: None, | |
| 1533 | 1548 | }); |
| 1534 | 1549 | source.paragraphs.insert(1, picture); |
| 1535 | 1550 | let editor = CanvasEditor::from_page( |
| ... | ... | @@ -2885,3 +2900,238 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { |
| 2885 | 2900 | assert_eq!(placed(&view).unwrap().layout.x, Some(504.0)); |
| 2886 | 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] | |
| 2908 | fn 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] | |
| 3023 | fn 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] | |
| 3083 | fn 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 | 104 | ]; |
| 105 | 105 | |
| 106 | 106 | /// en-US, what OneNote records when nothing better is known. |
| 107 | const EN_US: u32 = 1033; | |
| 107 | pub(crate) const EN_US: u32 = 1033; | |
| 108 | 108 | |
| 109 | 109 | /// The Windows LCID of a BCP-47 tag such as an input source's language (`de`, `en-GB`, |
| 110 | 110 | /// `zh-Hans`), matched by region, then script, then language, else en-US. |
| ... | ... | @@ -133,6 +133,15 @@ pub fn lcid(tag: &str) -> u32 { |
| 133 | 133 | .unwrap_or(EN_US) |
| 134 | 134 | } |
| 135 | 135 | |
| 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. | |
| 138 | pub fn tag(lcid: u32) -> Option<&'static str> { | |
| 139 | LCIDS | |
| 140 | .iter() | |
| 141 | .find(|(_, known)| *known == lcid) | |
| 142 | .map(|(tag, _)| *tag) | |
| 143 | } | |
| 144 | ||
| 136 | 145 | #[cfg(test)] |
| 137 | 146 | mod tests { |
| 138 | 147 | use super::*; |
| ... | ... | @@ -158,5 +167,8 @@ mod tests { |
| 158 | 167 | ] { |
| 159 | 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 | 638 | rect |
| 639 | 639 | } |
| 640 | 640 | |
| 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 | 651 | pub fn selection(&self, selection: Selection) -> Vec<BoundingBox> { |
| 642 | 652 | selection |
| 643 | 653 | .geometry(&self.shaped) |
crates/canvas/src/lib.rs+1| ... | ... | @@ -6,6 +6,7 @@ pub mod layout; |
| 6 | 6 | pub mod math; |
| 7 | 7 | pub mod outline; |
| 8 | 8 | pub mod search; |
| 9 | pub mod spelling; | |
| 9 | 10 | pub mod template; |
| 10 | 11 | |
| 11 | 12 | pub mod date; |
crates/canvas/src/outline.rs+168-17| ... | ... | @@ -66,6 +66,67 @@ pub struct ObjectLayout { |
| 66 | 66 | pub kind: ObjectKind, |
| 67 | 67 | /// Outline-local bottom of the whole object, label included. |
| 68 | 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)] | |
| 74 | pub 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 | ||
| 85 | impl 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 | } |
| 70 | 131 | |
| 71 | 132 | #[derive(Clone)] |
| ... | ... | @@ -121,6 +182,10 @@ impl ObjectLayout { |
| 121 | 182 | if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut self.kind { |
| 122 | 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 | 189 | flow |
| 125 | 190 | } |
| 126 | 191 | } |
| ... | ... | @@ -131,19 +196,27 @@ fn verticals<'a>( |
| 131 | 196 | tables: &'a mut [TableLayout], |
| 132 | 197 | objects: &'a mut [ObjectLayout], |
| 133 | 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 | 204 | paragraphs |
| 136 | 205 | .iter_mut() |
| 137 | 206 | .map(|paragraph| &mut paragraph.origin[1]) |
| 138 | .chain(cells.flat_map(|cell| { | |
| 139 | let [_, top, _, bottom] = &mut cell.rect; | |
| 140 | [top, bottom] | |
| 207 | .chain(tables.iter_mut().flat_map(move |table| { | |
| 208 | let cells = table.cells.iter_mut().flat_map(|cell| { | |
| 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 | 215 | let ObjectLayout { |
| 144 | 216 | rect: [_, top, _, end], |
| 145 | 217 | bottom, |
| 146 | 218 | kind, |
| 219 | tags: block, | |
| 147 | 220 | .. |
| 148 | 221 | } = object; |
| 149 | 222 | let label = match kind { |
| ... | ... | @@ -152,7 +225,10 @@ fn verticals<'a>( |
| 152 | 225 | } |
| 153 | 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 | } |
| 158 | 234 | |
| ... | ... | @@ -167,6 +243,7 @@ pub struct TableLayout { |
| 167 | 243 | /// Cells are in paragraph order with disjoint visible ranges. |
| 168 | 244 | pub cells: Vec<CellLayout>, |
| 169 | 245 | pub borders: bool, |
| 246 | pub tags: Option<BlockTags>, | |
| 170 | 247 | } |
| 171 | 248 | |
| 172 | 249 | #[derive(Clone)] |
| ... | ... | @@ -515,6 +592,7 @@ fn file_column( |
| 515 | 592 | rect: [left, 0.0, left + w, 0.0], |
| 516 | 593 | kind: ObjectKind::File(label), |
| 517 | 594 | bottom: 0.0, |
| 595 | tags: None, | |
| 518 | 596 | }; |
| 519 | 597 | Ok((object, h)) |
| 520 | 598 | } |
| ... | ... | @@ -1104,13 +1182,16 @@ impl ParagraphLayout { |
| 1104 | 1182 | self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min) |
| 1105 | 1183 | } |
| 1106 | 1184 | |
| 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 | 1185 | fn place_tags(&mut self, marker: Option<f32>) { |
| 1110 | for (index, tag) in self.tags.iter_mut().enumerate() { | |
| 1111 | tag.origin[0] = | |
| 1112 | ParagraphTag::column(self.origin[0], marker, tag.size) + tag.size * index as f32; | |
| 1113 | } | |
| 1186 | place_tags(&mut self.tags, self.origin[0], marker); | |
| 1187 | } | |
| 1188 | } | |
| 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. | |
| 1192 | fn 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 | } |
| 1116 | 1197 | |
| ... | ... | @@ -1136,10 +1217,39 @@ impl OutlineLayout { |
| 1136 | 1217 | -self |
| 1137 | 1218 | .paragraphs |
| 1138 | 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 | 1223 | .fold(0.0, f32::max) |
| 1141 | 1224 | } |
| 1142 | 1225 | |
| 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 | 1253 | /// Innermost table cell containing a visible paragraph index. |
| 1144 | 1254 | pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { |
| 1145 | 1255 | self.tables.iter().rev().find_map(|table| { |
| ... | ... | @@ -1172,15 +1282,22 @@ impl OutlineLayout { |
| 1172 | 1282 | *value += offset; |
| 1173 | 1283 | } |
| 1174 | 1284 | } |
| 1285 | if let Some(tags) = &mut table.tags { | |
| 1286 | tags.offset(origin); | |
| 1287 | } | |
| 1175 | 1288 | } |
| 1176 | 1289 | for object in &mut child.objects { |
| 1177 | 1290 | for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) { |
| 1178 | 1291 | *value += offset; |
| 1179 | 1292 | } |
| 1180 | 1293 | object.bottom += origin[1]; |
| 1294 | if let Some(tags) = &mut object.tags { | |
| 1295 | tags.offset(origin); | |
| 1296 | } | |
| 1181 | 1297 | if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind { |
| 1298 | let y = label.origin[1]; | |
| 1182 | 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 | 1303 | self.paragraphs.extend(child.paragraphs); |
| ... | ... | @@ -1231,6 +1348,21 @@ impl OutlineLayout { |
| 1231 | 1348 | let marker = markers.get(&paragraph.parent).copied(); |
| 1232 | 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 | } |
| 1235 | 1367 | |
| 1236 | 1368 | /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of |
| ... | ... | @@ -1299,6 +1431,11 @@ impl OutlineLayout { |
| 1299 | 1431 | let space = spacing(&node.format)?; |
| 1300 | 1432 | let y = top(&mut state, space); |
| 1301 | 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 | 1439 | if depth == 0 { |
| 1303 | 1440 | block.rel = verticals( |
| 1304 | 1441 | &mut child.paragraphs, |
| ... | ... | @@ -1325,6 +1462,7 @@ impl OutlineLayout { |
| 1325 | 1462 | rect: [x, 0.0, x + w, 0.0], |
| 1326 | 1463 | kind: ObjectKind::Picture, |
| 1327 | 1464 | bottom: 0.0, |
| 1465 | tags: None, | |
| 1328 | 1466 | }; |
| 1329 | 1467 | (object, h, x + w) |
| 1330 | 1468 | } |
| ... | ... | @@ -1345,6 +1483,7 @@ impl OutlineLayout { |
| 1345 | 1483 | rect: [x, 0.0, x + w, 0.0], |
| 1346 | 1484 | kind: ObjectKind::Ink(ink.clone()), |
| 1347 | 1485 | bottom: 0.0, |
| 1486 | tags: None, | |
| 1348 | 1487 | }; |
| 1349 | 1488 | (object, h, x + w) |
| 1350 | 1489 | } |
| ... | ... | @@ -1372,6 +1511,7 @@ impl OutlineLayout { |
| 1372 | 1511 | rect: [x, 0.0, x + w, 0.0], |
| 1373 | 1512 | kind: ObjectKind::Unsupported(label), |
| 1374 | 1513 | bottom: 0.0, |
| 1514 | tags: None, | |
| 1375 | 1515 | }; |
| 1376 | 1516 | (object, h, x + w) |
| 1377 | 1517 | } |
| ... | ... | @@ -1379,6 +1519,7 @@ impl OutlineLayout { |
| 1379 | 1519 | unreachable!("text and tables are not objects") |
| 1380 | 1520 | } |
| 1381 | 1521 | }; |
| 1522 | object.tags = BlockTags::new(node, &[], x, shape)?; | |
| 1382 | 1523 | let flow = object.place(y, height); |
| 1383 | 1524 | result.objects.push(object); |
| 1384 | 1525 | (space, height, flow, extent) |
| ... | ... | @@ -1755,14 +1896,12 @@ impl OutlineLayout { |
| 1755 | 1896 | { |
| 1756 | 1897 | return Err(LayoutError::InvalidWidth); |
| 1757 | 1898 | } |
| 1758 | if !table.tags.is_empty() { | |
| 1759 | return Err(LayoutError::UnsupportedContent); | |
| 1760 | } | |
| 1761 | 1899 | let mut result = Self { |
| 1762 | 1900 | tables: vec![TableLayout { |
| 1763 | 1901 | id: table.id, |
| 1764 | 1902 | cells: Vec::new(), |
| 1765 | 1903 | borders: table.borders.unwrap_or(true), |
| 1904 | tags: None, | |
| 1766 | 1905 | }], |
| 1767 | 1906 | size: [ |
| 1768 | 1907 | (0..table.columns.len()) |
| ... | ... | @@ -1799,6 +1938,17 @@ impl OutlineLayout { |
| 1799 | 1938 | for paragraph in &mut child.paragraphs { |
| 1800 | 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 | 1952 | let paragraph_start = result.paragraphs.len(); |
| 1803 | 1953 | result.append(child, [x, y + 3.54]); |
| 1804 | 1954 | result.tables[0].cells.push(CellLayout { |
| ... | ... | @@ -2614,6 +2764,7 @@ mod tests { |
| 2614 | 2764 | transparency: None, |
| 2615 | 2765 | pen_tip: None, |
| 2616 | 2766 | raster_operation: None, |
| 2767 | pressure: Vec::new(), | |
| 2617 | 2768 | }], |
| 2618 | 2769 | groups: Vec::new(), |
| 2619 | 2770 | shape: None, |
crates/canvas/src/search.rs+1-1| ... | ... | @@ -77,7 +77,7 @@ impl Query { |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | /// Ideographs and kana are words of their own, as their text has no spaces between words. |
| 80 | fn ideographic(character: char) -> bool { | |
| 80 | pub(crate) fn ideographic(character: char) -> bool { | |
| 81 | 81 | matches!(u32::from(character), |
| 82 | 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 | ||
| 7 | use onestore::page::text::Paragraph; | |
| 8 | use std::collections::{HashMap, HashSet}; | |
| 9 | use std::hash::{DefaultHasher, Hash, Hasher}; | |
| 10 | use std::ops::Range; | |
| 11 | use std::sync::{Arc, Mutex, mpsc}; | |
| 12 | ||
| 13 | /// Checked paragraphs kept before the cache starts over. | |
| 14 | const KEPT: usize = 8192; | |
| 15 | /// Paragraphs waiting to be checked that one call to the dictionary takes. | |
| 16 | const 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. | |
| 20 | pub 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. | |
| 32 | pub 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)] | |
| 63 | pub 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. | |
| 70 | struct 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. | |
| 78 | fn 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 | ||
| 87 | fn apostrophe(character: char) -> bool { | |
| 88 | matches!(character, '\'' | '\u{2019}' | '\u{02bc}') | |
| 89 | } | |
| 90 | ||
| 91 | /// An Internet or file address, which OneNote leaves unchecked whole. | |
| 92 | fn 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. | |
| 104 | fn 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. | |
| 170 | fn 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. | |
| 187 | fn 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. | |
| 205 | fn 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. | |
| 245 | fn 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 | ||
| 260 | struct Checked { | |
| 261 | text: String, | |
| 262 | marks: Vec<Mark>, | |
| 263 | } | |
| 264 | ||
| 265 | #[derive(Default)] | |
| 266 | struct 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 | ||
| 274 | impl 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 | ||
| 309 | struct 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)] | |
| 317 | pub struct Spelling { | |
| 318 | shared: Arc<Shared>, | |
| 319 | jobs: mpsc::Sender<(u64, Paragraph)>, | |
| 320 | } | |
| 321 | ||
| 322 | impl 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)] | |
| 405 | pub(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 | 43 | }, |
| 44 | 44 | size: art.size, |
| 45 | 45 | bytes: Some(bytes.into()), |
| 46 | display: None, | |
| 46 | 47 | alt: None, |
| 47 | 48 | background: true, |
| 49 | printout: None, | |
| 48 | 50 | }) |
| 49 | 51 | }) |
| 50 | 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 | ||
| 6 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | |
| 7 | use onestore::{ | |
| 8 | Arena, ExGuid, Section, Store, | |
| 9 | op::{Edit, Op}, | |
| 10 | page::{Page, PageObject, PageParagraph, ParagraphContent}, | |
| 11 | }; | |
| 12 | ||
| 13 | const 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)] | |
| 17 | enum Block { | |
| 18 | Text(String), | |
| 19 | Table(Vec<Vec<String>>), | |
| 20 | } | |
| 21 | ||
| 22 | fn 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 | ||
| 50 | fn at(paragraph: usize, offset: u32) -> TextPosition { | |
| 51 | TextPosition { paragraph, offset } | |
| 52 | } | |
| 53 | ||
| 54 | #[test] | |
| 55 | fn 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 | 24 | shape, |
| 25 | 25 | color, |
| 26 | 26 | highlight, |
| 27 | art: None, | |
| 27 | 28 | }; |
| 28 | 29 | vec![ |
| 29 | 30 | tag("Snow check", 61, Some(0x0000_0080), Some(0x00ff_cc00)), |
| ... | ... | @@ -173,6 +174,7 @@ fn onenotes_custom_tags_read_back() { |
| 173 | 174 | shape: 61, |
| 174 | 175 | color: Some(0x0000_0080), |
| 175 | 176 | highlight: Some(0x00ff_cc00), |
| 177 | art: None, | |
| 176 | 178 | }; |
| 177 | 179 | let to_do = NoteTag::defaults()[0].clone(); |
| 178 | 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 | 285 | assert!(lab.view.editor.take_ops().unwrap().is_empty()); |
| 286 | 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] | |
| 292 | fn 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 | 156 | let at = line(&mut editor, engine); |
| 157 | 157 | typed(&mut editor, engine, "Read the Rust book today"); |
| 158 | 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 | 163 | editor |
| 161 | 164 | .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/") |
| 162 | 165 | .unwrap(); |
| ... | ... | @@ -500,6 +503,265 @@ fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() { |
| 500 | 503 | assert_eq!(written, native); |
| 501 | 504 | } |
| 502 | 505 | |
| 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] | |
| 510 | fn 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] | |
| 555 | fn 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] | |
| 594 | fn 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] | |
| 708 | fn 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 | 765 | /// An equation among text draws in its line, which grows to hold it, and URL text shows as a |
| 504 | 766 | /// link without being stored as one. |
| 505 | 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 | ||
| 5 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | |
| 6 | use onestore::{ | |
| 7 | Arena, Section, Store, | |
| 8 | op::{Edit, Op}, | |
| 9 | page::{Page, PageObject, ParagraphContent}, | |
| 10 | }; | |
| 11 | ||
| 12 | const ALONE: &[u8] = include_bytes!("../../../corpus/object-outline/native/notebook/Alone.one"); | |
| 13 | ||
| 14 | /// The body outline's paragraphs: an object's kind, or text. | |
| 15 | fn 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] | |
| 36 | fn 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 | 598 | }, |
| 599 | 599 | size: Some([174.5, 640.0]), |
| 600 | 600 | bytes: Some(std::sync::Arc::from(ART.as_slice())), |
| 601 | display: None, | |
| 601 | 602 | alt: None, |
| 602 | 603 | background: true, |
| 604 | printout: None, | |
| 603 | 605 | }; |
| 604 | 606 | let shown = editor.page().unwrap().objects.iter().any(|object| { |
| 605 | 607 | matches!(object, onestore::page::PageObject::Image(image) if image.background) |
| ... | ... | @@ -710,10 +712,16 @@ fn perform( |
| 710 | 712 | let points: Vec<[f32; 2]> = (0..12) |
| 711 | 713 | .map(|step| [x + 6.0 * step as f32, y + 9.0 * (step as f32 / 2.0).sin()]) |
| 712 | 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 | 721 | let ink = onestore::page::Ink { |
| 714 | 722 | id: onestore::page::text::new_id().unwrap(), |
| 715 | 723 | layout: Default::default(), |
| 716 | strokes: vec![pen.stroke(&points).unwrap()], | |
| 724 | strokes: vec![pen.stroke(&points, &pressure).unwrap()], | |
| 717 | 725 | groups: Vec::new(), |
| 718 | 726 | shape: None, |
| 719 | 727 | }; |
| ... | ... | @@ -735,7 +743,7 @@ fn perform( |
| 735 | 743 | from[0] + 18.0 * (1 + random.below(6)) as f32, |
| 736 | 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 | 747 | let ink = onestore::page::Ink::drawn(kinds[random.below(4)], from, to, &pen).unwrap(); |
| 740 | 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 | ||
| 6 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | |
| 7 | use onestore::{ | |
| 8 | Arena, Section, Store, | |
| 9 | op::{Edit, Op}, | |
| 10 | page::{Image, Page, PageObject}, | |
| 11 | }; | |
| 12 | ||
| 13 | const PRINTOUTS: &[u8] = include_bytes!("../../../corpus/printout/native/notebook/Printouts.one"); | |
| 14 | ||
| 15 | fn 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] | |
| 28 | fn 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 | 185 | assert_eq!(shown[3].1.time_ms, Some(20131)); |
| 186 | 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] | |
| 193 | fn 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 | ||
| 5 | use canvas::{ | |
| 6 | document::TextPosition, | |
| 7 | editor::{CanvasEditor, Whole}, | |
| 8 | layout::TextEngine, | |
| 9 | }; | |
| 10 | use onestore::{ | |
| 11 | Arena, ExGuid, Section, Store, | |
| 12 | document::Tag, | |
| 13 | op::{Edit, Op}, | |
| 14 | page::{Page, PageObject, PageParagraph, ParagraphContent}, | |
| 15 | }; | |
| 16 | ||
| 17 | const 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. | |
| 20 | fn 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 | ||
| 55 | fn 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 | ||
| 65 | fn 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] | |
| 78 | fn 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 | 8 | default = ["wgpu"] |
| 9 | 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through |
| 10 | 10 | # OpenGL 2.1, for Mac OS X 10.6. |
| 11 | render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | |
| 11 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | |
| 12 | 12 | wgpu = ["render", "dep:wgpu"] |
| 13 | 13 | |
| 14 | 14 | [dependencies] |
| 15 | 15 | parley.workspace = true |
| 16 | 16 | |
| 17 | base64 = { version = "0.22.1", optional = true } | |
| 17 | 18 | bytemuck = { version = "1.25", features = ["derive"], optional = true } |
| 18 | 19 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } |
| 19 | 20 | linebender_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 | 82 | return value; |
| 83 | 83 | } |
| 84 | 84 | |
| 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). | |
| 87 | float 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 | ||
| 85 | 106 | void main() { |
| 86 | 107 | vec4 color = texture2D(atlas, v_uv) * v_color; |
| 87 | 108 | vec4 shape = v_shape; |
| 88 | 109 | if (v_blur > 1e-6) { |
| 89 | 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 | 113 | } else if (shape.x > 0.0 && shape.y > 0.0) { |
| 91 | 114 | vec2 q = abs(v_local) - shape.xy + shape.zw; |
| 92 | 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 | 67 | return value; |
| 68 | 68 | } |
| 69 | 69 | |
| 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). | |
| 72 | fn 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 | 91 | @fragment |
| 71 | 92 | fn fragment(input: Vertex) -> @location(0) vec4<f32> { |
| 72 | 93 | var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; |
| 73 | 94 | if input.blur > 0.0 { |
| 74 | 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 | 98 | } else if input.shape.x > 0.0 && input.shape.y > 0.0 { |
| 76 | 99 | let q = abs(input.local) - input.shape.xy + input.shape.zw; |
| 77 | 100 | var distance: f32; |
crates/draw/src/edit.rs+1| ... | ... | @@ -32,6 +32,7 @@ pub enum NamedKey { |
| 32 | 32 | End, |
| 33 | 33 | PageUp, |
| 34 | 34 | PageDown, |
| 35 | F7, | |
| 35 | 36 | F11, |
| 36 | 37 | /// Shift, Control, Option or Command pressed alone. |
| 37 | 38 | Modifier, |
crates/draw/src/render.rs+39-10| ... | ... | @@ -13,7 +13,7 @@ use gl as backend; |
| 13 | 13 | use webgpu as backend; |
| 14 | 14 | |
| 15 | 15 | pub use backend::Target; |
| 16 | pub use icon::Palette; | |
| 16 | pub use icon::{Palette, picture_icon}; | |
| 17 | 17 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 18 | 18 | #[cfg(feature = "wgpu")] |
| 19 | 19 | pub use translucent::Translucent; |
| ... | ... | @@ -205,7 +205,8 @@ struct Vertex { |
| 205 | 205 | /// that rectangle's half size and corner radius; a zero half size paints everywhere. |
| 206 | 206 | clip_local: [f32; 2], |
| 207 | 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 | 210 | blur: f32, |
| 210 | 211 | } |
| 211 | 212 | |
| ... | ... | @@ -429,6 +430,14 @@ pub enum Primitive<'a> { |
| 429 | 430 | round: bool, |
| 430 | 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 | 441 | /// A highlighter's stroke between two points, `width` units across with square ends: |
| 433 | 442 | /// its opaque `color` multiplies what lies beneath, so text under it stays dark. |
| 434 | 443 | Highlight { |
| ... | ... | @@ -860,7 +869,13 @@ impl Renderer { |
| 860 | 869 | width, |
| 861 | 870 | round, |
| 862 | 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 | 879 | Primitive::Highlight { |
| 865 | 880 | from, |
| 866 | 881 | to, |
| ... | ... | @@ -868,7 +883,7 @@ impl Renderer { |
| 868 | 883 | color, |
| 869 | 884 | } => { |
| 870 | 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 | 888 | Primitive::Image { image, rect } => { |
| 874 | 889 | if let Some(rect) = space.visible_rect(*rect)? { |
| ... | ... | @@ -1348,13 +1363,14 @@ impl Renderer { |
| 1348 | 1363 | Ok(()) |
| 1349 | 1364 | } |
| 1350 | 1365 | |
| 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 | 1368 | fn segment( |
| 1353 | 1369 | &mut self, |
| 1354 | 1370 | space: Space, |
| 1355 | 1371 | from: [f32; 2], |
| 1356 | 1372 | to: [f32; 2], |
| 1357 | width: f32, | |
| 1373 | widths: [f32; 2], | |
| 1358 | 1374 | round: bool, |
| 1359 | 1375 | color: [f32; 4], |
| 1360 | 1376 | ) -> Result<(), RenderError> { |
| ... | ... | @@ -1366,8 +1382,15 @@ impl Renderer { |
| 1366 | 1382 | }; |
| 1367 | 1383 | let [from, to] = [pixel(from), pixel(to)]; |
| 1368 | 1384 | // Hairlines stay one device pixel wide. |
| 1369 | let radius = (width * space.scale).max(1.0) * 0.5; | |
| 1370 | if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { | |
| 1385 | let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5); | |
| 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 | 1394 | return Err(RenderError::InvalidPrimitive); |
| 1372 | 1395 | } |
| 1373 | 1396 | let pad = radius + 1.0; |
| ... | ... | @@ -1392,6 +1415,12 @@ impl Renderer { |
| 1392 | 1415 | let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; |
| 1393 | 1416 | let half = [length * 0.5 + radius, radius]; |
| 1394 | 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 | 1424 | let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; |
| 1396 | 1425 | for local in [ |
| 1397 | 1426 | [-hx, -hy], |
| ... | ... | @@ -1411,11 +1440,11 @@ impl Renderer { |
| 1411 | 1440 | uv: [0.5 / self.atlas_side() as f32; 2], |
| 1412 | 1441 | color, |
| 1413 | 1442 | local, |
| 1414 | shape: [half[0], half[1], corner, corner], | |
| 1443 | shape, | |
| 1415 | 1444 | stroke: 0.0, |
| 1416 | 1445 | clip_local: [0.0; 2], |
| 1417 | 1446 | clip: [0.0; 3], |
| 1418 | blur: 0.0, | |
| 1447 | blur, | |
| 1419 | 1448 | }); |
| 1420 | 1449 | } |
| 1421 | 1450 | Ok(()) |
crates/draw/src/render/icon.rs+91-1| ... | ... | @@ -35,7 +35,8 @@ impl Palette { |
| 35 | 35 | /// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round |
| 36 | 36 | /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, |
| 37 | 37 | /// 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. | |
| 39 | 40 | pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image { |
| 40 | 41 | let side = size; |
| 41 | 42 | let size = size as f32; |
| ... | ... | @@ -43,6 +44,9 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa |
| 43 | 44 | for source in sources { |
| 44 | 45 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); |
| 45 | 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 | 50 | let paint = |value: &str| -> Option<Paint> { |
| 47 | 51 | match value { |
| 48 | 52 | "none" => None, |
| ... | ... | @@ -174,6 +178,61 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa |
| 174 | 178 | } |
| 175 | 179 | } |
| 176 | 180 | |
| 181 | /// An icon source drawing the PNG `png` centred in the icon at its own proportions; none | |
| 182 | /// where it is not a picture. | |
| 183 | pub 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. | |
| 200 | fn 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 | ||
| 177 | 236 | fn hex(value: &str) -> [f32; 4] { |
| 178 | 237 | let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) |
| 179 | 238 | .unwrap() |
| ... | ... | @@ -550,6 +609,37 @@ mod tests { |
| 550 | 609 | assert_eq!(alpha(1, 8), 64); |
| 551 | 610 | } |
| 552 | 611 | |
| 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 | 643 | #[test] |
| 554 | 644 | fn icons_keep_transparency_and_overlays_across_scales() { |
| 555 | 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 | 20 | serde = { version = "1", features = ["derive"] } |
| 21 | 21 | serde_json = "1" |
| 22 | 22 | |
| 23 | [target.'cfg(target_os = "ios")'.dependencies] | |
| 24 | objc2 = "0.5.2" | |
| 25 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSRange", "NSString", "NSThread"] } | |
| 26 | ||
| 23 | 27 | [dev-dependencies] |
| 24 | 28 | tempfile = "3" |
crates/mobile/include/snowbound.h+10| ... | ... | @@ -15,6 +15,9 @@ void sb_string_free(char *text); |
| 15 | 15 | typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context); |
| 16 | 16 | void sb_set_coordinator(sb_coordinator coordinator); |
| 17 | 17 | void sb_set_sync_wake(void (*wake)(void)); |
| 18 | typedef void (*sb_versions)(const char *path, void (*found)(void *, const char *id, const char *device), void *context); | |
| 19 | typedef bool (*sb_retire)(const char *path, const char *id, bool keep); | |
| 20 | void sb_set_versions(sb_versions versions, sb_retire retire); | |
| 18 | 21 | char *sb_tags(void); |
| 19 | 22 | char *sb_pens(uint32_t section); |
| 20 | 23 | 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); |
| 22 | 25 | const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); |
| 23 | 26 | const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, |
| 24 | 27 | const char *domain, const char *root, const char *cache, char **error); |
| 28 | bool sb_notebook_create(const char *path, const char *cache, const char *author, const char *date, const char *time, | |
| 29 | char **error); | |
| 30 | bool sb_notebook_moved(const char *cache, const char *from, const char *to, char **error); | |
| 25 | 31 | char *sb_library_sections(const Library *library); |
| 26 | 32 | char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, |
| 27 | 33 | const char *date, const char *time); |
| ... | ... | @@ -31,6 +37,7 @@ char *sb_library_sync_status(const Library *library); |
| 31 | 37 | void sb_library_set_offline(const Library *library, bool offline); |
| 32 | 38 | void sb_library_touched(const Library *library, const char *path); |
| 33 | 39 | void sb_library_sync_now(const Library *library); |
| 40 | bool sb_library_close(const Library *library, double seconds, char **error); | |
| 34 | 41 | void sb_library_free(const Library *library); |
| 35 | 42 | |
| 36 | 43 | Share *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 | 69 | bool sb_view_block(const View *view, float x, float y, float rect[4]); |
| 63 | 70 | void sb_view_set_transform(View *view, float zoom, float x, float y); |
| 64 | 71 | uint8_t sb_view_target(const View *view, float x, float y); |
| 72 | void sb_view_pressure(View *view, float pressure); | |
| 65 | 73 | bool sb_view_press(View *view, float x, float y); |
| 66 | 74 | bool sb_view_drag(View *view, float x, float y); |
| 67 | 75 | bool sb_view_release(View *view); |
| ... | ... | @@ -69,6 +77,8 @@ bool sb_view_undo(View *view, bool redo); |
| 69 | 77 | bool sb_view_can_undo(const View *view, bool redo); |
| 70 | 78 | uint64_t sb_view_format(const View *view); |
| 71 | 79 | bool sb_view_apply(View *view, uint8_t command); |
| 80 | char *sb_view_correction(const View *view); | |
| 81 | bool sb_view_correct(View *view, uint8_t command, const char *text); | |
| 72 | 82 | char *sb_view_title(const View *view); |
| 73 | 83 | bool sb_view_focus_title(View *view); |
| 74 | 84 | bool sb_view_find(View *view, const char *query); |
crates/mobile/src/lib.rs+79-2| ... | ... | @@ -8,6 +8,8 @@ |
| 8 | 8 | //! the host redraws and rereads `sb_view_content`. |
| 9 | 9 | |
| 10 | 10 | mod library; |
| 11 | #[cfg(target_os = "ios")] | |
| 12 | mod spell; | |
| 11 | 13 | |
| 12 | 14 | use library::Shared; |
| 13 | 15 | pub use library::{Library, Section, Share}; |
| ... | ... | @@ -512,8 +514,10 @@ impl Canvas { |
| 512 | 514 | }, |
| 513 | 515 | size: Some(size), |
| 514 | 516 | bytes: Some(bytes.into()), |
| 517 | display: None, | |
| 515 | 518 | alt: None, |
| 516 | 519 | background: false, |
| 520 | printout: None, | |
| 517 | 521 | }; |
| 518 | 522 | let page = &mut self.page; |
| 519 | 523 | page.editor.insert_picture(&mut page.engine, image)?; |
| ... | ... | @@ -681,11 +685,21 @@ impl View { |
| 681 | 685 | .ok_or("No supported surface configuration")?; |
| 682 | 686 | surface.configure(&gpu.renderer.device, &config); |
| 683 | 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 | 698 | Ok(Self { |
| 685 | canvas: ManuallyDrop::new(Canvas::new(space, page, pixels, scale)?), | |
| 699 | canvas: ManuallyDrop::new(canvas), | |
| 686 | 700 | surface, |
| 687 | 701 | config, |
| 688 | frame: Arc::default(), | |
| 702 | frame, | |
| 689 | 703 | section: Arc::clone(&section.shared), |
| 690 | 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 | 930 | view.stored(result) |
| 917 | 931 | } |
| 918 | 932 | |
| 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)] | |
| 936 | pub 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 | 942 | #[unsafe(no_mangle)] |
| 920 | 943 | pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { |
| 921 | 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 | 983 | view.stored(result) |
| 961 | 984 | } |
| 962 | 985 | |
| 986 | /// The marked word at the caret, or selected, as JSON `{"word", "suggestions", | |
| 987 | /// "repeated"}`; null where there is none. | |
| 988 | #[unsafe(no_mangle)] | |
| 989 | pub 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)] | |
| 1014 | pub 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 | 1040 | /// The page title's text; null on a page without an editable title. |
| 964 | 1041 | #[unsafe(no_mangle)] |
| 965 | 1042 | pub 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 | 6 | use canvas::search::{Entry, Index, Query, Tagged}; |
| 7 | 7 | use notebook::{ |
| 8 | 8 | Remote, Replica, |
| 9 | discover::{Folder, SectionState}, | |
| 9 | discover::{Folder, Reason, SectionState}, | |
| 10 | 10 | session::{self, Background, Event, Known, Notebook, SyncStatus}, |
| 11 | 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 | 87 | .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again.")) |
| 88 | 88 | } |
| 89 | 89 | |
| 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. | |
| 93 | pub 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. | |
| 101 | pub type Retire = extern "C" fn(path: *const c_char, id: *const c_char, keep: bool) -> bool; | |
| 102 | ||
| 103 | static 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)] | |
| 108 | pub extern "C" fn sb_set_versions(versions: Versions, retire: Retire) { | |
| 109 | let _ = VERSIONS.set((versions, retire)); | |
| 110 | } | |
| 111 | ||
| 90 | 112 | /// A local section file read and published under the host's file coordination. |
| 91 | 113 | struct Coordinated(PathBuf); |
| 92 | 114 | |
| ... | ... | @@ -116,6 +138,42 @@ impl Remote for Coordinated { |
| 116 | 138 | fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { |
| 117 | 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 | } |
| 120 | 178 | |
| 121 | 179 | fn uncommitted(error: io::Error) -> CommitError { |
| ... | ... | @@ -169,7 +227,7 @@ enum Place { |
| 169 | 227 | #[derive(serde::Serialize, serde::Deserialize)] |
| 170 | 228 | struct Listed { |
| 171 | 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 | 231 | files: BTreeMap<String, String>, |
| 174 | 232 | } |
| 175 | 233 | |
| ... | ... | @@ -177,10 +235,14 @@ fn hex(bytes: &[u8]) -> String { |
| 177 | 235 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() |
| 178 | 236 | } |
| 179 | 237 | |
| 180 | /// Every section's file identity in `folder` and its groups, by catalog path. | |
| 181 | fn files(folder: &Folder, files: &mut BTreeMap<String, String>) { | |
| 238 | /// Every readable section's file identity in `folder` and its groups, by catalog path, as | |
| 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. | |
| 241 | pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) { | |
| 182 | 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 | 247 | for group in &folder.groups { |
| 186 | 248 | self::files(group, files); |
| ... | ... | @@ -205,8 +267,15 @@ pub struct Library { |
| 205 | 267 | open: Mutex<Vec<(String, Weak<Shared>)>>, |
| 206 | 268 | /// Work Offline, which sections opened later follow too. |
| 207 | 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 | } |
| 209 | 275 | |
| 276 | /// The pause edits to a file a provider keeps elsewhere wait for. | |
| 277 | const PAUSE: Duration = Duration::from_secs(3); | |
| 278 | ||
| 210 | 279 | #[derive(serde::Serialize, serde::Deserialize)] |
| 211 | 280 | pub(crate) struct Tab { |
| 212 | 281 | pub(crate) name: String, |
| ... | ... | @@ -218,6 +287,15 @@ pub(crate) struct Tab { |
| 218 | 287 | pub(crate) color: [u8; 3], |
| 219 | 288 | /// Password-protected or unreadable sections list but do not open. |
| 220 | 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 | } |
| 222 | 300 | |
| 223 | 301 | fn rgb(colorref: u32) -> [u8; 3] { |
| ... | ... | @@ -246,6 +324,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 246 | 324 | group: folder.path.clone(), |
| 247 | 325 | color: rgb(color.unwrap_or(SECTION_COLOR)), |
| 248 | 326 | readable, |
| 327 | downloading: false, | |
| 328 | copy: None, | |
| 329 | problem: None, | |
| 249 | 330 | }); |
| 250 | 331 | } |
| 251 | 332 | for entry in folder.unavailable.iter().filter(|entry| !entry.group) { |
| ... | ... | @@ -255,6 +336,16 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 255 | 336 | group: folder.path.clone(), |
| 256 | 337 | color: rgb(SECTION_COLOR), |
| 257 | 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 | 351 | for group in &folder.groups { |
| ... | ... | @@ -294,6 +385,7 @@ impl Library { |
| 294 | 385 | background, |
| 295 | 386 | open: Mutex::default(), |
| 296 | 387 | offline: AtomicBool::new(false), |
| 388 | pause: if local { Duration::ZERO } else { PAUSE }, | |
| 297 | 389 | }) |
| 298 | 390 | } |
| 299 | 391 | |
| ... | ... | @@ -315,6 +407,7 @@ impl Library { |
| 315 | 407 | background: Some(background), |
| 316 | 408 | open: Mutex::default(), |
| 317 | 409 | offline: AtomicBool::new(false), |
| 410 | pause: Duration::ZERO, | |
| 318 | 411 | }; |
| 319 | 412 | let reached = library.with_notebook(false, |_| Ok(())); |
| 320 | 413 | match library.listed() { |
| ... | ... | @@ -326,16 +419,15 @@ impl Library { |
| 326 | 419 | |
| 327 | 420 | /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them. |
| 328 | 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 | 423 | return; |
| 331 | 424 | }; |
| 332 | let smb = self.cache.join("smb"); | |
| 333 | 425 | background.watch( |
| 334 | 426 | files |
| 335 | 427 | .iter() |
| 336 | 428 | .map(|(path, identity)| Known { |
| 337 | 429 | path: path.clone(), |
| 338 | replica: Some(smb.join(format!("{identity}.sqlite"))), | |
| 430 | replica: Some(replicas.join(format!("{identity}.sqlite"))), | |
| 339 | 431 | found: None, |
| 340 | 432 | image: None, |
| 341 | 433 | }) |
| ... | ... | @@ -343,6 +435,15 @@ impl Library { |
| 343 | 435 | ); |
| 344 | 436 | } |
| 345 | 437 | |
| 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 | 447 | /// Where a share notebook's last listing is kept. |
| 347 | 448 | fn listing(&self) -> Option<PathBuf> { |
| 348 | 449 | let Place::Share { server, root, .. } = &self.place else { |
| ... | ... | @@ -418,6 +519,9 @@ impl Library { |
| 418 | 519 | group: String::new(), |
| 419 | 520 | color: rgb(SECTION_COLOR), |
| 420 | 521 | readable: true, |
| 522 | downloading: false, | |
| 523 | copy: None, | |
| 524 | problem: None, | |
| 421 | 525 | }]); |
| 422 | 526 | } |
| 423 | 527 | let listed = self.with_notebook(false, |notebook| { |
| ... | ... | @@ -472,6 +576,7 @@ impl Library { |
| 472 | 576 | } |
| 473 | 577 | }; |
| 474 | 578 | section.set_offline(self.offline.load(Ordering::Relaxed)); |
| 579 | section.set_pause(self.pause); | |
| 475 | 580 | if let Some(background) = &self.background { |
| 476 | 581 | background.hold(path, &section); |
| 477 | 582 | } |
| ... | ... | @@ -505,13 +610,15 @@ impl Library { |
| 505 | 610 | .listed() |
| 506 | 611 | .and_then(|mut listed| listed.files.remove(path)) |
| 507 | 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 | 615 | let replica = if cache.exists() { |
| 510 | 616 | Replica::open(&cache)? |
| 511 | 617 | } else { |
| 512 | 618 | let client = self |
| 513 | 619 | .client() |
| 514 | 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 | 622 | Replica::create(&cache, &client.read_storage(&file, LIMIT)?)? |
| 516 | 623 | }; |
| 517 | 624 | let server = Arc::clone(server); |
| ... | ... | @@ -904,6 +1011,71 @@ impl Library { |
| 904 | 1011 | .collect() |
| 905 | 1012 | } |
| 906 | 1013 | |
| 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 | 1079 | fn open_sections(&self) -> Vec<(String, Arc<Shared>)> { |
| 908 | 1080 | let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner()); |
| 909 | 1081 | open.retain(|(_, shared)| shared.strong_count() > 0); |
| ... | ... | @@ -1091,8 +1263,56 @@ pub unsafe extern "C" fn sb_library_server( |
| 1091 | 1263 | } |
| 1092 | 1264 | } |
| 1093 | 1265 | |
| 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)] | |
| 1273 | pub 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)] | |
| 1295 | pub 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 | 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 | 1316 | #[unsafe(no_mangle)] |
| 1097 | 1317 | pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char { |
| 1098 | 1318 | json(library.tabs()) |
| ... | ... | @@ -1202,6 +1422,24 @@ pub extern "C" fn sb_library_sync_now(library: &Library) { |
| 1202 | 1422 | } |
| 1203 | 1423 | } |
| 1204 | 1424 | |
| 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)] | |
| 1432 | pub 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 | 1443 | /// # Safety |
| 1206 | 1444 | /// `library` came from `sb_library_open` or `sb_library_server` and is not used again. |
| 1207 | 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 | ||
| 4 | use canvas::spelling::{Dictionary, pick}; | |
| 5 | use objc2::{class, msg_send, msg_send_id, rc::Retained, runtime::AnyObject}; | |
| 6 | use objc2_foundation::{MainThreadMarker, NSArray, NSRange, NSString}; | |
| 7 | use std::ffi::c_void; | |
| 8 | ||
| 9 | unsafe 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. | |
| 16 | fn 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 | ||
| 35 | fn checker() -> Retained<AnyObject> { | |
| 36 | unsafe { msg_send_id![class!(UITextChecker), new] } | |
| 37 | } | |
| 38 | ||
| 39 | struct Checker { | |
| 40 | /// The spelling languages the system has, as `en_US`. | |
| 41 | languages: Vec<String>, | |
| 42 | } | |
| 43 | ||
| 44 | impl 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. | |
| 95 | pub 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 | 104 | assert_eq!(lone.tabs().unwrap().len(), 1); |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | #[test] | |
| 108 | fn 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 | 136 | #[test] |
| 108 | 137 | fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { |
| 109 | 138 | let (_directory, section) = features(); |
| ... | ... | @@ -496,22 +525,24 @@ fn to_do_marks_a_new_pages_body() { |
| 496 | 525 | assert_eq!(tags, 1); |
| 497 | 526 | } |
| 498 | 527 | |
| 499 | /// Against the Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share `agent`, guest): a | |
| 500 | /// notebook folder copied to the share lists, a section opens and publishes typing, and the | |
| 501 | /// listing kept in the cache opens the section again while the server cannot be reached. | |
| 528 | /// Against a disposable share `agent` (`ONESTORE_SMB_LAB=HOST:PORT`, as | |
| 529 | /// `SNOWBOUND_TEST_SMB_USER` with `SNOWBOUND_TEST_SMB_PASSWORD`, or a guest): a notebook | |
| 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 | 532 | #[test] |
| 503 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | |
| 533 | #[ignore = "requires ONESTORE_SMB_LAB pointing to a disposable share"] | |
| 504 | 534 | fn a_notebook_on_a_share_saves_and_opens_offline() { |
| 505 | 535 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); |
| 536 | let variable = |name| std::env::var(name).unwrap_or_default(); | |
| 506 | 537 | let server = |address: &str| library::Server { |
| 507 | 538 | address: address.into(), |
| 508 | 539 | share: "agent".into(), |
| 509 | user: String::new(), | |
| 510 | password: String::new(), | |
| 540 | user: variable("SNOWBOUND_TEST_SMB_USER"), | |
| 541 | password: variable("SNOWBOUND_TEST_SMB_PASSWORD"), | |
| 511 | 542 | domain: String::new(), |
| 512 | 543 | }; |
| 513 | 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 | 546 | client.create_directory(&root).unwrap(); |
| 516 | 547 | let source = corpus("media-edit/candidate/Features.one"); |
| 517 | 548 | client |
| ... | ... | @@ -552,6 +583,12 @@ fn a_notebook_on_a_share_saves_and_opens_offline() { |
| 552 | 583 | )) |
| 553 | 584 | }; |
| 554 | 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 | 592 | let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap()); |
| 556 | 593 | assert_eq!(offline.tabs().unwrap()[0].path, "Features.one"); |
| 557 | 594 | let reopened = open(&offline, "Features.one"); |
| ... | ... | @@ -561,6 +598,23 @@ fn a_notebook_on_a_share_saves_and_opens_offline() { |
| 561 | 598 | .unwrap() |
| 562 | 599 | .0; |
| 563 | 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] | |
| 606 | fn 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 | } |
| 565 | 619 | |
| 566 | 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 | 1019 | assert!(!canvas.page.editor.can_redo()); |
| 966 | 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. | |
| 1025 | fn 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 | ||
| 1047 | fn 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] | |
| 1063 | fn 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] | |
| 1078 | fn 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 | 20 | tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } |
| 21 | 21 | zeroize = { version = "1.9.0", optional = true } |
| 22 | 22 | |
| 23 | [target.'cfg(target_vendor = "apple")'.dependencies] | |
| 24 | nix = { version = "0.31", default-features = false, features = ["fs"] } | |
| 25 | ||
| 23 | 26 | [dev-dependencies] |
| 24 | 27 | libc = "0.2" |
| 25 | 28 | serde_json = "1" |
crates/notebook/README.md+49-3| ... | ... | @@ -62,6 +62,8 @@ failures; `notify` runs on a background thread whenever an event waits. |
| 62 | 62 | removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an |
| 63 | 63 | edit names is the host's: the app passes the account's full name. |
| 64 | 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 | |
| 66 | first waiting), as a notebook on a cloud drive does; `wake()` publishes them at once. | |
| 65 | 67 | `sync_status()` gives when the section file was last reached, why it could not be since |
| 66 | 68 | and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the |
| 67 | 69 | worker 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 | 117 | of the local one, placed the same way (`corpus/conflict-page/native-pages`, |
| 116 | 118 | `native-restore`). Nothing blocks the queue. |
| 117 | 119 | |
| 120 | A 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 | |
| 122 | merges them first (`resolve.rs`): per object space, the newest revision of the version's that | |
| 123 | the file holds, or holds merged, is where the two last agreed; the section opened at those | |
| 124 | revisions (`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 | |
| 126 | they 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 | |
| 128 | again. It is published on the file's stamp, then retired; one of another section, or one that | |
| 129 | cannot be read, is retired with `keep` for the remote to keep beside the file. | |
| 130 | ||
| 118 | 131 | Publication attempts are recorded before network I/O. An attempt confirms only |
| 119 | 132 | when the remote holds its revisions, or every page it changed as it changed them, |
| 120 | 133 | followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never |
| ... | ... | @@ -273,9 +286,12 @@ cache as it was. |
| 273 | 286 | |
| 274 | 287 | Each file read must be a consistent, bounded snapshot. The result is an |
| 275 | 288 | observation across multiple files, not an atomic notebook transaction or |
| 276 | authorization to publish an edit. Refresh rejects observed topology changes and | |
| 277 | duplicate physical files with the same logical identity. Retain the last accepted | |
| 278 | catalog if discovery fails; a connection failure does not mean files were deleted. | |
| 289 | authorization to publish an edit. Refresh rejects observed topology changes. Of | |
| 290 | several files with one identity (a `Name 2.one` copy), the one its folder's TOC names, | |
| 291 | else 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`. | |
| 293 | Retain the last accepted catalog if discovery fails; a connection failure does not mean | |
| 294 | files were deleted. | |
| 279 | 295 | |
| 280 | 296 | `read_external_asset` resolves a validated UUID `.onebin` filename beneath the |
| 281 | 297 | selected 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 | 358 | the previous catalog, so an unreachable share never reads as an emptied |
| 343 | 359 | notebook. |
| 344 | 360 | |
| 361 | ## Snowbound's folder | |
| 362 | ||
| 363 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which | |
| 364 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it | |
| 365 | through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a | |
| 366 | dot name hidden without storing it) or macOS's `UF_HIDDEN`, which an smbfs mount passes | |
| 367 | to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook | |
| 368 | without 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, | |
| 377 | keeps the picture, then rereads `tags.json`, merges its mapping and replaces the file, | |
| 378 | reading it back and merging again until its own holds. `sidecar::merge` keeps every tag | |
| 379 | either side mapped; one tag mapped by both keeps the later `mapped`, then the greater art | |
| 380 | name. A mapping two writers replace within one round trip of each other can still lose | |
| 381 | one side's new tag until that side maps it again. `tag_art` reads the mappings, passing | |
| 382 | over entries that name no picture, and `tag_art_file` returns a picture once its bytes | |
| 383 | match its name. | |
| 384 | ||
| 345 | 385 | ## SMB (feature `smb`) |
| 346 | 386 | |
| 347 | 387 | `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 | 439 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 400 | 440 | have distinct I/O error kinds. |
| 401 | 441 | |
| 442 | `smb::shares(address, credentials, timeout)` lists the disk shares a server offers the | |
| 443 | account, 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 | |
| 445 | refused, no such share, denied, no such folder. A server that turns SMB2's negotiation | |
| 446 | away is asked for SMB1's `NT LM 0.12` once, which an SMB1-only server agrees to. | |
| 447 | ||
| 402 | 448 | `Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as |
| 403 | 449 | OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without |
| 404 | 450 | probing 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 | 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 | 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 | 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 | 914 | fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), onestore::CommitError> { |
| 908 | 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 | } |
| 911 | 930 | |
| 912 | 931 | #[cfg(test)] |
crates/notebook/src/discover.rs+260-58| ... | ... | @@ -8,12 +8,14 @@ use serde::{Deserialize, Serialize}; |
| 8 | 8 | use std::{ |
| 9 | 9 | collections::{BTreeMap, BTreeSet}, |
| 10 | 10 | io, |
| 11 | time::Duration, | |
| 11 | 12 | }; |
| 12 | 13 | |
| 13 | 14 | mod source; |
| 14 | 15 | pub use source::Local; |
| 15 | 16 | #[cfg(feature = "smb")] |
| 16 | 17 | pub use source::Smb; |
| 18 | pub(crate) use source::placeholder; | |
| 17 | 19 | |
| 18 | 20 | #[derive(Debug, Serialize)] |
| 19 | 21 | pub struct Section { |
| ... | ... | @@ -21,6 +23,10 @@ pub struct Section { |
| 21 | 23 | /// Header.guidFile, also used by FileIdentityGuid in a parent TOC. |
| 22 | 24 | pub file_id: [u8; 16], |
| 23 | 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 | } |
| 25 | 31 | |
| 26 | 32 | #[derive(Debug, Serialize)] |
| ... | ... | @@ -54,6 +60,31 @@ pub struct Unavailable { |
| 54 | 60 | pub path: String, |
| 55 | 61 | pub group: bool, |
| 56 | 62 | pub error: String, |
| 63 | pub reason: Reason, | |
| 64 | } | |
| 65 | ||
| 66 | #[derive(Debug, PartialEq, Eq, Serialize)] | |
| 67 | pub 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. | |
| 82 | struct 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 | } |
| 58 | 89 | |
| 59 | 90 | #[derive(Debug, Serialize)] |
| ... | ... | @@ -77,6 +108,9 @@ pub enum EntryKind { |
| 77 | 108 | File, |
| 78 | 109 | Directory, |
| 79 | 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 | } |
| 81 | 115 | |
| 82 | 116 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] |
| ... | ... | @@ -107,6 +141,9 @@ pub struct Cache { |
| 107 | 141 | |
| 108 | 142 | /// The most bytes of images a cache holds for `Cache::take`. |
| 109 | 143 | const HELD: usize = 64 << 20; |
| 144 | /// How many more times a read that meets a commit in progress is tried, and how far apart. | |
| 145 | const RETRIES: usize = 3; | |
| 146 | const RETRY: Duration = Duration::from_millis(250); | |
| 110 | 147 | |
| 111 | 148 | /// A notebook file as discovery read it. |
| 112 | 149 | #[derive(Clone, Serialize, Deserialize)] |
| ... | ... | @@ -135,17 +172,18 @@ impl Cache { |
| 135 | 172 | /// A failed discovery leaves the cache as it was. |
| 136 | 173 | pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { |
| 137 | 174 | let mut remaining = limits.entries; |
| 138 | let mut identities = BTreeMap::new(); | |
| 175 | let mut claims = BTreeMap::new(); | |
| 139 | 176 | let mut found = Cache::default(); |
| 140 | let folder = scan( | |
| 177 | let mut folder = scan( | |
| 141 | 178 | source, |
| 142 | 179 | "", |
| 143 | 180 | &limits, |
| 144 | 181 | 0, |
| 145 | 182 | &mut remaining, |
| 146 | &mut identities, | |
| 183 | &mut claims, | |
| 147 | 184 | (&self.read, &mut found), |
| 148 | 185 | )?; |
| 186 | set_aside(&mut folder, &claims); | |
| 149 | 187 | *self = found; |
| 150 | 188 | Ok(folder) |
| 151 | 189 | } |
| ... | ... | @@ -259,12 +297,6 @@ pub enum Error { |
| 259 | 297 | Changed { path: String }, |
| 260 | 298 | #[error("Invalid or unsupported directory entry: {path}")] |
| 261 | 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 | } |
| 269 | 301 | |
| 270 | 302 | /// Discovers rooted notebook topology within caller-specified work and size limits. |
| ... | ... | @@ -279,7 +311,7 @@ fn scan( |
| 279 | 311 | limits: &Limits, |
| 280 | 312 | depth: usize, |
| 281 | 313 | remaining: &mut usize, |
| 282 | identities: &mut BTreeMap<[u8; 16], String>, | |
| 314 | claims: &mut BTreeMap<[u8; 16], Vec<Claim>>, | |
| 283 | 315 | (cached, found): (&BTreeMap<String, Read>, &mut Cache), |
| 284 | 316 | ) -> Result<Folder, Error> { |
| 285 | 317 | if depth > limits.depth { |
| ... | ... | @@ -324,18 +356,19 @@ fn scan( |
| 324 | 356 | limits, |
| 325 | 357 | depth + 1, |
| 326 | 358 | remaining, |
| 327 | identities, | |
| 359 | claims, | |
| 328 | 360 | (cached, found), |
| 329 | 361 | ) { |
| 330 | 362 | Ok(group) => result.groups.push(group), |
| 331 | Err(error @ Error::Io { .. }) if unavailable(&error) => { | |
| 332 | result.unavailable.push(Unavailable { | |
| 363 | Err(error) => match unavailable(&error) { | |
| 364 | Some(reason) => result.unavailable.push(Unavailable { | |
| 333 | 365 | path: child, |
| 334 | 366 | group: true, |
| 335 | 367 | error: error.to_string(), |
| 336 | }); | |
| 337 | } | |
| 338 | Err(error) => return Err(error), | |
| 368 | reason, | |
| 369 | }), | |
| 370 | None => return Err(error), | |
| 371 | }, | |
| 339 | 372 | } |
| 340 | 373 | continue; |
| 341 | 374 | } |
| ... | ... | @@ -347,6 +380,19 @@ fn scan( |
| 347 | 380 | } else { |
| 348 | 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 | 396 | if entry.kind != EntryKind::File |
| 351 | 397 | || (expected == FileType::TableOfContents && result.toc.is_some()) |
| 352 | 398 | { |
| ... | ... | @@ -367,41 +413,71 @@ fn scan( |
| 367 | 413 | .and_then(Read::file_id) |
| 368 | 414 | .and_then(|known| source.copy(&child, known)); |
| 369 | 415 | let fetched = copied.is_none(); |
| 370 | let bytes = | |
| 371 | match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) { | |
| 372 | Ok(bytes) => bytes, | |
| 373 | Err(error) => { | |
| 374 | let error = Error::Io { | |
| 375 | path: child.clone(), | |
| 376 | error, | |
| 377 | }; | |
| 378 | if expected == FileType::Section && unavailable(&error) { | |
| 379 | result.unavailable.push(Unavailable { | |
| 380 | path: child, | |
| 381 | group: false, | |
| 382 | error: error.to_string(), | |
| 383 | }); | |
| 384 | continue; | |
| 416 | // A section that cannot be read lists as unavailable; the rest still open. | |
| 417 | let mut list_unavailable = |error: Error| match unavailable(&error) { | |
| 418 | Some(reason) if expected == FileType::Section => { | |
| 419 | result.unavailable.push(Unavailable { | |
| 420 | path: child.clone(), | |
| 421 | group: false, | |
| 422 | error: error.to_string(), | |
| 423 | reason, | |
| 424 | }); | |
| 425 | Ok(()) | |
| 426 | } | |
| 427 | _ => Err(error), | |
| 428 | }; | |
| 429 | let read = copied.map_or_else( | |
| 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 | 459 | if bytes.len() > limits.bytes_per_file { |
| 390 | 460 | return Err(Error::Limit { path: child }); |
| 391 | 461 | } |
| 392 | let store = Store::parse(&bytes).map_err(|error| Error::Document { | |
| 393 | path: child.clone(), | |
| 394 | 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 { | |
| 462 | let parsed = Store::parse(&bytes).and_then(|store| { | |
| 463 | if store.header.file_type != expected || !store.checksum_mismatches.is_empty() { | |
| 464 | return Err(onestore::Error { | |
| 400 | 465 | offset: 0, |
| 401 | 466 | message: "Unexpected file type or checksum mismatch", |
| 402 | }, | |
| 403 | }); | |
| 404 | } | |
| 467 | }); | |
| 468 | } | |
| 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 | 481 | let held = held(&store, expected); |
| 406 | 482 | let file_id = store.header.file_id; |
| 407 | 483 | if let Ok(held) = &held { |
| ... | ... | @@ -438,11 +514,13 @@ fn scan( |
| 438 | 514 | color, |
| 439 | 515 | document, |
| 440 | 516 | }, |
| 517 | copy: false, | |
| 441 | 518 | }), |
| 442 | 519 | Ok(Held::Locked) => result.sections.push(Section { |
| 443 | 520 | path: child.clone(), |
| 444 | 521 | file_id, |
| 445 | 522 | state: SectionState::Locked, |
| 523 | copy: false, | |
| 446 | 524 | }), |
| 447 | 525 | Ok(Held::Toc(unresolved)) => { |
| 448 | 526 | result.toc = Some(Toc { |
| ... | ... | @@ -455,16 +533,10 @@ fn scan( |
| 455 | 533 | path: child.clone(), |
| 456 | 534 | file_id, |
| 457 | 535 | state: SectionState::Unreadable(error), |
| 536 | copy: false, | |
| 458 | 537 | }), |
| 459 | 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 | 541 | let order: BTreeMap<_, _> = result |
| 470 | 542 | .toc |
| ... | ... | @@ -492,6 +564,39 @@ fn scan( |
| 492 | 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 | 600 | let present: BTreeSet<_> = result |
| 496 | 601 | .sections |
| 497 | 602 | .iter() |
| ... | ... | @@ -528,6 +633,96 @@ fn scan( |
| 528 | 633 | Ok(result) |
| 529 | 634 | } |
| 530 | 635 | |
| 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. | |
| 640 | fn 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 | ||
| 680 | fn 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 | ||
| 699 | fn 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. | |
| 709 | pub(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 | ||
| 718 | fn 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 | 726 | /// What discovery takes from the file `store` holds, a section or a TOC as `expected`. |
| 532 | 727 | fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { |
| 533 | 728 | let index = RevisionIndex::parse(store)?; |
| ... | ... | @@ -540,7 +735,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { |
| 540 | 735 | .and_then(|id| revision.nodes.get(id)) |
| 541 | 736 | }; |
| 542 | 737 | if expected == FileType::Section { |
| 543 | if root(1).is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) { | |
| 738 | if encrypted(revision) { | |
| 544 | 739 | return Ok(Held::Locked); |
| 545 | 740 | } |
| 546 | 741 | index.validate_current()?; |
| ... | ... | @@ -592,12 +787,19 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> { |
| 592 | 787 | Ok(Held::Toc(unresolved)) |
| 593 | 788 | } |
| 594 | 789 | |
| 595 | /// Access denied or a file gone mid-listing; a lost connection stays fatal. | |
| 596 | fn unavailable(error: &Error) -> bool { | |
| 597 | matches!(error, Error::Io { error, .. } if matches!( | |
| 598 | error.kind(), | |
| 599 | io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound | |
| 600 | )) | |
| 790 | /// Why a file or group that failed lists as unavailable; `None` fails the whole discovery, | |
| 791 | /// as a lost connection does. | |
| 792 | fn unavailable(error: &Error) -> Option<Reason> { | |
| 793 | match error { | |
| 794 | Error::Io { error, .. } => match error.kind() { | |
| 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 | } |
| 602 | 804 | |
| 603 | 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 | 1 | use super::{Entry, EntryKind, Listed, Source, component}; |
| 2 | 2 | use std::{ |
| 3 | collections::BTreeSet, | |
| 3 | 4 | io, |
| 4 | 5 | path::{Path, PathBuf}, |
| 5 | 6 | }; |
| 6 | 7 | |
| 8 | /// The notebook file iOS lists as `.Name.icloud` while iCloud Drive keeps it elsewhere. | |
| 9 | fn 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. | |
| 16 | pub(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 | 21 | /// A canonical local root; symbolic-link entries are not traversed. |
| 8 | 22 | pub struct Local { |
| 9 | 23 | root: PathBuf, |
| ... | ... | @@ -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 | 89 | Ok(entries) |
| 66 | 90 | } |
| 67 | 91 |
crates/notebook/src/lib.rs+13| ... | ... | @@ -22,13 +22,17 @@ use std::{ |
| 22 | 22 | mod assets; |
| 23 | 23 | mod background; |
| 24 | 24 | mod base; |
| 25 | pub mod location; | |
| 25 | 26 | mod merge; |
| 26 | 27 | mod migrate; |
| 27 | 28 | mod queue; |
| 28 | 29 | mod recovery; |
| 30 | mod resolve; | |
| 31 | pub use resolve::Version; | |
| 29 | 32 | mod schema; |
| 30 | 33 | pub mod session; |
| 31 | 34 | pub use recovery::{Recovery, RecoverySummary}; |
| 35 | pub mod sidecar; | |
| 32 | 36 | mod sync; |
| 33 | 37 | pub use sync::{EditStatus, Remote, Synced}; |
| 34 | 38 | mod worker; |
| ... | ... | @@ -341,6 +345,15 @@ fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> { |
| 341 | 345 | .map_err(|_| io::Error::other("Cache owner panicked").into()) |
| 342 | 346 | } |
| 343 | 347 | |
| 348 | /// Tells the worker a burst of local edits is durable. | |
| 349 | fn 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 | ||
| 344 | 357 | fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) { |
| 345 | 358 | if let Ok(worker) = worker.lock() |
| 346 | 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 | ||
| 9 | use onestore::{Header, Stamp}; | |
| 10 | use std::{ | |
| 11 | io, | |
| 12 | path::{Path, PathBuf}, | |
| 13 | }; | |
| 14 | ||
| 15 | /// The file in a replica folder naming the location its replicas belong to. | |
| 16 | const LOCATION: &str = "location"; | |
| 17 | ||
| 18 | /// The folder in `cache` holding the replicas of the sections at `location`. | |
| 19 | pub 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. | |
| 27 | pub 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. | |
| 39 | pub 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. | |
| 47 | pub 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. | |
| 64 | fn 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. | |
| 87 | pub(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. | |
| 134 | fn 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`. | |
| 149 | fn 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`. | |
| 158 | fn 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. | |
| 172 | fn 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 | ||
| 182 | fn 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 | 187 | /// applies it to `new`, the merged section: a conflict page under the remote's page |
| 188 | 188 | /// marking `objects` where `page` holds them, or, where the remote removed the page, the |
| 189 | 189 | /// 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 model | |
| 191 | /// stays on the remote's page only; `at` is now. | |
| 190 | /// queue leaves it, that the remote did not move (`moved`). The page it makes is in the space | |
| 191 | /// `guid` names, or a fresh one. Content outside the page model stays on the remote's page | |
| 192 | /// only; `at` is now. | |
| 192 | 193 | #[allow(clippy::too_many_arguments)] |
| 193 | 194 | pub(crate) fn conflict_page( |
| 194 | 195 | new: &mut Section<'_>, |
| ... | ... | @@ -199,7 +200,12 @@ pub(crate) fn conflict_page( |
| 199 | 200 | author: &str, |
| 200 | 201 | objects: &BTreeSet<ExGuid>, |
| 201 | 202 | at: u64, |
| 203 | guid: Option<[u8; 16]>, | |
| 202 | 204 | ) -> Result<Edit> { |
| 205 | let placed = |creation: PageCreation| match guid { | |
| 206 | Some(guid) => creation.in_space(guid), | |
| 207 | None => Ok(creation), | |
| 208 | }; | |
| 203 | 209 | let mut page = page.clone(); |
| 204 | 210 | page.objects |
| 205 | 211 | .retain(|object| !matches!(object, PageObject::Unsupported(_))); |
| ... | ... | @@ -218,7 +224,11 @@ pub(crate) fn conflict_page( |
| 218 | 224 | .any(|object| matches!(object, PageObject::Title(_))); |
| 219 | 225 | vec![Op::Section(SectionOp::Conflict { |
| 220 | 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 | 232 | page: copy, |
| 223 | 233 | objects: marked, |
| 224 | 234 | })] |
| ... | ... | @@ -231,7 +241,8 @@ pub(crate) fn conflict_page( |
| 231 | 241 | .iter() |
| 232 | 242 | .map(|(next, _)| *next) |
| 233 | 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 | 246 | let mut ops = vec![Op::Section(SectionOp::Import { |
| 236 | 247 | creation: creation.clone(), |
| 237 | 248 | page: page.copy()?, |
| ... | ... | @@ -252,7 +263,7 @@ pub(crate) fn conflict_page( |
| 252 | 263 | /// Pages the base and the remote both list that the remote moved: into another series or |
| 253 | 264 | /// level, or out of base order among the rest. OneNote 2010 and this writer give a moved |
| 254 | 265 | /// page a series of its own; older builds of this writer moved a page within its series. |
| 255 | fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid>> { | |
| 266 | pub(crate) fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid>> { | |
| 256 | 267 | let series = (old.series()?, new.series()?); |
| 257 | 268 | let base: BTreeMap<ExGuid, (usize, u32)> = order(old)? |
| 258 | 269 | .into_iter() |
| ... | ... | @@ -293,6 +304,26 @@ fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid |
| 293 | 304 | .collect()) |
| 294 | 305 | } |
| 295 | 306 | |
| 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. | |
| 310 | fn series_start(section: &mut Section<'_>, before: Option<ExGuid>) -> Result<Option<ExGuid>> { | |
| 311 | Ok(starting(&order(section)?, &section.series()?, before)) | |
| 312 | } | |
| 313 | ||
| 314 | fn 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 | 327 | /// Applies a queued page-list edit to `local`, the page order as the queue leaves it. |
| 297 | 328 | fn advance(local: &mut Order, op: &SectionOp) { |
| 298 | 329 | let insert = |local: &mut Order, page: (ExGuid, u32), before: Option<ExGuid>| { |
| ... | ... | @@ -444,8 +475,10 @@ impl Replay { |
| 444 | 475 | .flat_map(|(_, pages)| pages.into_iter().map(|page| page.space)) |
| 445 | 476 | .collect(); |
| 446 | 477 | let listed = order(new)?; |
| 478 | let series = new.series()?; | |
| 447 | 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 | 483 | Ok(match op { |
| 451 | 484 | SectionOp::Create(creation) => Some(SectionOp::Create(anchored(creation)?)), |
crates/notebook/src/queue.rs+2| ... | ... | @@ -278,8 +278,10 @@ mod tests { |
| 278 | 278 | layout: Default::default(), |
| 279 | 279 | size: None, |
| 280 | 280 | bytes: Some(Arc::clone(&bytes)), |
| 281 | display: None, | |
| 281 | 282 | alt: None, |
| 282 | 283 | background: false, |
| 284 | printout: None, | |
| 283 | 285 | }) |
| 284 | 286 | }; |
| 285 | 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 | ||
| 13 | use crate::{PendingEdit, Result, merge}; | |
| 14 | use onestore::{ | |
| 15 | Arena, ConflictPage, ExGuid, PageCreation, PageEdit, RevisionIndex, Section, Store, | |
| 16 | Transaction, | |
| 17 | op::{Edit, Op, SectionOp, lower_page}, | |
| 18 | }; | |
| 19 | use sha2::{Digest, Sha256}; | |
| 20 | use 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)] | |
| 24 | pub 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. | |
| 33 | pub(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. | |
| 44 | fn 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. | |
| 58 | pub(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. | |
| 288 | fn 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 | 82 | fn location(&self) -> String; |
| 83 | 83 | fn exists(&self, path: &str) -> bool; |
| 84 | 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 | 87 | /// Creates a file holding `bytes`; an existing file is an error. |
| 86 | 88 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()>; |
| 87 | 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 | 92 | /// Renames or moves a file or directory; an existing target is an error. |
| 89 | 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 | 96 | /// Deletes a file or an empty directory. |
| 91 | 97 | fn delete(&self, path: &str) -> Result<()>; |
| 92 | 98 | /// Names a section or TOC file for its notebook, as `onestore::place`. |
| ... | ... | @@ -137,8 +143,24 @@ impl Storage for Directory { |
| 137 | 143 | Ok(onestore::read_file(self.path(path))?) |
| 138 | 144 | } |
| 139 | 145 | |
| 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 | 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 | 164 | Ok(()) |
| 143 | 165 | } |
| 144 | 166 | |
| ... | ... | @@ -146,10 +168,32 @@ impl Storage for Directory { |
| 146 | 168 | Ok(std::fs::create_dir(self.path(path))?) |
| 147 | 169 | } |
| 148 | 170 | |
| 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 | 189 | fn rename(&self, from: &str, to: &str) -> Result<()> { |
| 150 | 190 | Ok(std::fs::rename(self.path(from), self.path(to))?) |
| 151 | 191 | } |
| 152 | 192 | |
| 193 | fn replace(&self, from: &str, to: &str) -> Result<()> { | |
| 194 | Ok(std::fs::rename(self.path(from), self.path(to))?) | |
| 195 | } | |
| 196 | ||
| 153 | 197 | fn delete(&self, path: &str) -> Result<()> { |
| 154 | 198 | let path = self.path(path); |
| 155 | 199 | if path.is_dir() { |
| ... | ... | @@ -201,7 +245,7 @@ impl Storage for Share { |
| 201 | 245 | } |
| 202 | 246 | |
| 203 | 247 | fn location(&self) -> String { |
| 204 | format!("{}/{}", self.client.share(), self.root) | |
| 248 | self.client.location(&self.root) | |
| 205 | 249 | } |
| 206 | 250 | |
| 207 | 251 | fn exists(&self, path: &str) -> bool { |
| ... | ... | @@ -217,6 +261,10 @@ impl Storage for Share { |
| 217 | 261 | .read_storage(&self.path(path), 256 * 1024 * 1024)?) |
| 218 | 262 | } |
| 219 | 263 | |
| 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 | 268 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { |
| 221 | 269 | Ok(self.client.create(&self.path(path), bytes)?) |
| 222 | 270 | } |
| ... | ... | @@ -225,10 +273,18 @@ impl Storage for Share { |
| 225 | 273 | Ok(self.client.create_directory(&self.path(path))?) |
| 226 | 274 | } |
| 227 | 275 | |
| 276 | fn hide(&self, path: &str) -> Result<()> { | |
| 277 | Ok(self.client.hide(&self.path(path))?) | |
| 278 | } | |
| 279 | ||
| 228 | 280 | fn rename(&self, from: &str, to: &str) -> Result<()> { |
| 229 | 281 | Ok(self.client.rename(&self.path(from), &self.path(to))?) |
| 230 | 282 | } |
| 231 | 283 | |
| 284 | fn replace(&self, from: &str, to: &str) -> Result<()> { | |
| 285 | Ok(self.client.replace(&self.path(from), &self.path(to))?) | |
| 286 | } | |
| 287 | ||
| 232 | 288 | fn delete(&self, path: &str) -> Result<()> { |
| 233 | 289 | Ok(self.client.delete(&self.path(path))?) |
| 234 | 290 | } |
| ... | ... | @@ -266,6 +322,10 @@ pub struct Notebook { |
| 266 | 322 | } |
| 267 | 323 | |
| 268 | 324 | impl 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 | 329 | pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> { |
| 270 | 330 | let root = root.as_ref().canonicalize()?; |
| 271 | 331 | Self::with(Box::new(Directory(root.clone())), Some(root), cache) |
| ... | ... | @@ -297,7 +357,7 @@ impl Notebook { |
| 297 | 357 | cache: impl AsRef<Path>, |
| 298 | 358 | ) -> Result<Self> { |
| 299 | 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 | 361 | Self::with( |
| 302 | 362 | Box::new(Share { |
| 303 | 363 | client, |
| ... | ... | @@ -327,6 +387,31 @@ impl Notebook { |
| 327 | 387 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 328 | 388 | .unwrap_or_default(); |
| 329 | 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 | 415 | let notebook = Self { |
| 331 | 416 | storage, |
| 332 | 417 | root, |
| ... | ... | @@ -355,6 +440,30 @@ impl Notebook { |
| 355 | 440 | &self.catalog |
| 356 | 441 | } |
| 357 | 442 | |
| 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 | 467 | /// Rereads the notebook and reports what changed since the last catalog, keyed by |
| 359 | 468 | /// file identity so a renamed or moved section stays the same section. A failed read |
| 360 | 469 | /// keeps the previous catalog: an unreachable notebook is not an empty one. |
| ... | ... | @@ -941,24 +1050,32 @@ impl Notebook { |
| 941 | 1050 | Ok(()) |
| 942 | 1051 | } |
| 943 | 1052 | |
| 944 | /// Where the replica of the section at catalog `path` lives: named by the section's | |
| 945 | /// document identity in a mounted notebook (`Section::open`), by its file identity under | |
| 946 | /// `smb` on a share. It exists once the section has been opened. | |
| 1053 | /// Where the replica of the section at catalog `path` lives, in the notebook's | |
| 1054 | /// `location::folder`: named by the section's document identity in a mounted notebook | |
| 1055 | /// (`Section::open`), by its file identity on a share. It exists once the section has | |
| 1056 | /// been opened. | |
| 947 | 1057 | pub fn replica_path(&self, path: &str) -> Result<PathBuf> { |
| 948 | 1058 | let section = self.section_path(path)?; |
| 1059 | let folder = self.replica_folder(section); | |
| 949 | 1060 | Ok(match (&self.root, &section.state) { |
| 950 | 1061 | (Some(_), discover::SectionState::Readable { document, .. }) => { |
| 951 | replica_file(&self.cache, document) | |
| 1062 | replica_file(&folder, document) | |
| 952 | 1063 | } |
| 953 | 1064 | (Some(_), _) => { |
| 954 | 1065 | let image = self.storage.read(&section.path)?; |
| 955 | 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 | } |
| 961 | 1072 | |
| 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 | 1079 | /// Every readable section, for `Background::watch`, handing on the files the last |
| 963 | 1080 | /// discovery read so that each is read once. |
| 964 | 1081 | pub fn replicas(&mut self) -> Vec<Known> { |
| ... | ... | @@ -1040,7 +1157,8 @@ impl Notebook { |
| 1040 | 1157 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1041 | 1158 | notify: impl Fn() + Send + 'static, |
| 1042 | 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 | 1162 | let Some(root) = &self.root else { |
| 1045 | 1163 | return Err(io::Error::new( |
| 1046 | 1164 | io::ErrorKind::Unsupported, |
| ... | ... | @@ -1053,7 +1171,11 @@ impl Notebook { |
| 1053 | 1171 | if !file.starts_with(root) { |
| 1054 | 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 | } |
| 1059 | 1181 | |
| ... | ... | @@ -1156,6 +1278,17 @@ fn catalog_path(folder: &str, name: &str) -> String { |
| 1156 | 1278 | } |
| 1157 | 1279 | } |
| 1158 | 1280 | |
| 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. | |
| 1284 | fn 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 | 1292 | /// The replica in `cache` of the section `identity` names. |
| 1160 | 1293 | pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { |
| 1161 | 1294 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); |
| ... | ... | @@ -1212,6 +1345,10 @@ pub enum SyncState { |
| 1212 | 1345 | NotConnected, |
| 1213 | 1346 | /// The file cannot be written where it is stored. |
| 1214 | 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 | 1352 | Failed, |
| 1216 | 1353 | } |
| 1217 | 1354 | |
| ... | ... | @@ -1221,6 +1358,8 @@ impl SyncStatus { |
| 1221 | 1358 | match self.error.as_ref().map(io::Error::kind) { |
| 1222 | 1359 | Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly, |
| 1223 | 1360 | Some(WouldBlock | ResourceBusy) => SyncState::InUse, |
| 1361 | Some(Unsupported) => SyncState::Protected, | |
| 1362 | Some(InvalidData) => SyncState::Unreadable, | |
| 1224 | 1363 | Some( |
| 1225 | 1364 | NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected |
| 1226 | 1365 | | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe |
| ... | ... | @@ -1249,9 +1388,9 @@ pub struct Section { |
| 1249 | 1388 | } |
| 1250 | 1389 | |
| 1251 | 1390 | impl Section { |
| 1252 | /// Opens the section file through a replica in `cache`, creating the replica from the | |
| 1253 | /// file on first use and converting an older one. `notify` runs on a background thread | |
| 1254 | /// whenever an event is available. | |
| 1391 | /// Opens the lone section file through a replica in `cache`, in the file's | |
| 1392 | /// `location::folder`, creating the replica from the file on first use and converting an | |
| 1393 | /// older one. `notify` runs on a background thread whenever an event is available. | |
| 1255 | 1394 | pub fn open( |
| 1256 | 1395 | file: impl AsRef<Path>, |
| 1257 | 1396 | cache: impl AsRef<Path>, |
| ... | ... | @@ -1266,15 +1405,32 @@ impl Section { |
| 1266 | 1405 | pub fn open_with<R: Remote + 'static>( |
| 1267 | 1406 | file: impl AsRef<Path>, |
| 1268 | 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 | 1409 | notify: impl Fn() + Send + 'static, |
| 1271 | 1410 | ) -> Result<Self> { |
| 1272 | 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 | 1430 | let source = connect(&file)?.read()?; |
| 1274 | 1431 | let store = Store::parse(&source)?; |
| 1275 | 1432 | let identity = RevisionIndex::parse(&store)?.root; |
| 1276 | std::fs::create_dir_all(&cache)?; | |
| 1277 | let cache = replica_file(cache.as_ref(), &identity.guid); | |
| 1433 | let cache = replica(&identity.guid, &source)?; | |
| 1278 | 1434 | let replica = if cache.exists() { |
| 1279 | 1435 | Replica::open(&cache)? |
| 1280 | 1436 | } else { |
| ... | ... | @@ -1556,6 +1712,14 @@ impl Section { |
| 1556 | 1712 | } |
| 1557 | 1713 | } |
| 1558 | 1714 | |
| 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 | 1723 | /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until |
| 1560 | 1724 | /// working online again (OneNote's Work Offline). |
| 1561 | 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 | ||
| 14 | use crate::{Result, session::Storage}; | |
| 15 | use serde::{Deserialize, Serialize}; | |
| 16 | use std::io; | |
| 17 | ||
| 18 | const FOLDER: &str = ".snowbound"; | |
| 19 | const MAPPING: &str = ".snowbound/tags.json"; | |
| 20 | const ART: &str = ".snowbound/tags"; | |
| 21 | /// The most bytes read of a mapping or a picture. | |
| 22 | pub const LIMIT: usize = 1 << 20; | |
| 23 | ||
| 24 | /// A tag drawn with art of Snowbound's. | |
| 25 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 26 | pub 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`. | |
| 37 | pub 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. | |
| 44 | fn 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. | |
| 55 | pub 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. | |
| 72 | pub(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. | |
| 92 | pub(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. | |
| 106 | pub(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. | |
| 159 | fn 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)] | |
| 166 | mod tests; |
crates/notebook/src/sidecar/tests.rs created+294| ... | ... | @@ -0,0 +1,294 @@ |
| 1 | use super::*; | |
| 2 | use crate::{Error, discover}; | |
| 3 | use onestore::Transaction; | |
| 4 | use std::{ | |
| 5 | collections::{BTreeMap, BTreeSet}, | |
| 6 | sync::Mutex, | |
| 7 | }; | |
| 8 | ||
| 9 | fn 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] | |
| 19 | fn 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)] | |
| 56 | struct 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 | ||
| 66 | fn missing() -> Error { | |
| 67 | io::Error::from(io::ErrorKind::NotFound).into() | |
| 68 | } | |
| 69 | ||
| 70 | impl 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 | ||
| 77 | impl 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] | |
| 171 | fn 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] | |
| 212 | fn 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] | |
| 227 | fn 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] | |
| 271 | fn 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 | 30 | pub use directory::DirectoryEntry; |
| 31 | 31 | pub use remote::SmbRemote; |
| 32 | 32 | |
| 33 | /// FILE_ATTRIBUTE_HIDDEN. | |
| 34 | pub const HIDDEN: u32 = 0x2; | |
| 35 | ||
| 33 | 36 | #[derive(Default)] |
| 34 | 37 | pub struct Credentials<'a> { |
| 35 | 38 | pub username: &'a str, |
| ... | ... | @@ -42,15 +45,17 @@ pub struct Credentials<'a> { |
| 42 | 45 | /// Paths are relative to the share; both `/` and `\` are separators. |
| 43 | 46 | pub struct Client { |
| 44 | 47 | connection: Mutex<Option<Connection>>, |
| 48 | /// The server as `connect` was given it, which names the share's `location`. | |
| 49 | address: String, | |
| 45 | 50 | tree: Tree, |
| 46 | 51 | timeout: Duration, |
| 47 | 52 | runtime: Mutex<Option<Runtime>>, |
| 48 | 53 | } |
| 49 | 54 | |
| 50 | 55 | impl Client { |
| 51 | /// The server and share, as `//server/share`. | |
| 52 | pub(crate) fn share(&self) -> String { | |
| 53 | format!("//{}/{}", self.tree.server, self.tree.share_name) | |
| 56 | /// The notebook folder `root` on this share, as `crate::location` names it. | |
| 57 | pub(crate) fn location(&self, root: &str) -> String { | |
| 58 | crate::location::smb(&self.address, &self.tree.share_name, root) | |
| 54 | 59 | } |
| 55 | 60 | |
| 56 | 61 | pub fn connect( |
| ... | ... | @@ -69,25 +74,18 @@ impl Client { |
| 69 | 74 | let (connection, tree) = runtime |
| 70 | 75 | .block_on(async { |
| 71 | 76 | tokio::time::timeout(timeout, async { |
| 72 | let mut connection = Connection::connect(address, timeout).await?; | |
| 73 | connection.set_compression_requested(false); | |
| 74 | connection.negotiate().await?; | |
| 75 | Session::setup( | |
| 76 | &mut connection, | |
| 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)) | |
| 77 | let mut connection = sign_in(address, &credentials, timeout).await?; | |
| 78 | let tree = Tree::connect(&mut connection, share) | |
| 79 | .await | |
| 80 | .map_err(io::Error::other)?; | |
| 81 | Ok::<_, io::Error>((connection, tree)) | |
| 84 | 82 | }) |
| 85 | 83 | .await |
| 86 | 84 | }) |
| 87 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? | |
| 88 | .map_err(io::Error::other)?; | |
| 85 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??; | |
| 89 | 86 | Ok(Self { |
| 90 | 87 | connection: Mutex::new(Some(connection)), |
| 88 | address: address.to_owned(), | |
| 91 | 89 | tree, |
| 92 | 90 | timeout, |
| 93 | 91 | runtime: Mutex::new(Some(runtime)), |
| ... | ... | @@ -211,6 +209,7 @@ impl Client { |
| 211 | 209 | 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, |
| 212 | 210 | 0xc0000011 => io::ErrorKind::UnexpectedEof, |
| 213 | 211 | 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, |
| 212 | 0xc0000035 => io::ErrorKind::AlreadyExists, | |
| 214 | 213 | // Windows reports a delete-pending file as access denied too. |
| 215 | 214 | 0xc0000022 | 0xc0000056 => io::ErrorKind::PermissionDenied, |
| 216 | 215 | 0xc0000103 => io::ErrorKind::NotADirectory, |
| ... | ... | @@ -288,6 +287,15 @@ impl Client { |
| 288 | 287 | /// Renames or moves a file or directory within the share; an existing target is an |
| 289 | 288 | /// error. |
| 290 | 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 | 299 | if to.is_empty() || to.contains('\0') { |
| 292 | 300 | return Err(io::ErrorKind::InvalidInput.into()); |
| 293 | 301 | } |
| ... | ... | @@ -303,7 +311,9 @@ impl Client { |
| 303 | 311 | .encode_utf16() |
| 304 | 312 | .flat_map(u16::to_le_bytes) |
| 305 | 313 | .collect(); |
| 314 | // FileRenameInformation: ReplaceIfExists, then reserved bytes and no root directory. | |
| 306 | 315 | let mut buffer = vec![0; 16]; |
| 316 | buffer[0] = u8::from(replace); | |
| 307 | 317 | buffer.extend_from_slice(&u32::try_from(name.len()).unwrap().to_le_bytes()); |
| 308 | 318 | buffer.extend_from_slice(&name); |
| 309 | 319 | let _: SetInfoResponse = self.request( |
| ... | ... | @@ -319,6 +329,47 @@ impl Client { |
| 319 | 329 | file.close() |
| 320 | 330 | } |
| 321 | 331 | |
| 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 | 373 | /// Deletes a file or an empty directory. |
| 323 | 374 | pub fn delete(&self, path: &str) -> io::Result<()> { |
| 324 | 375 | self.open_with(path, 0x00010000, 7, CreateDisposition::FileOpen, 0x1000)? |
| ... | ... | @@ -363,7 +414,7 @@ impl Client { |
| 363 | 414 | /// Reads one bounded, consistent snapshot; contention returns WouldBlock. |
| 364 | 415 | pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { |
| 365 | 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 | } |
| 369 | 420 | |
| ... | ... | @@ -466,6 +517,158 @@ impl Client { |
| 466 | 517 | } |
| 467 | 518 | } |
| 468 | 519 | |
| 520 | /// The disk shares the server at `address` offers the account, by name. | |
| 521 | pub 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`. | |
| 549 | async 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. | |
| 579 | async 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)] | |
| 608 | pub 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 | ||
| 624 | impl 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 | ||
| 638 | impl std::error::Error for Refusal {} | |
| 639 | ||
| 640 | impl 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 | ||
| 469 | 672 | impl Drop for Client { |
| 470 | 673 | fn drop(&mut self) { |
| 471 | 674 | self.retire(); |
| ... | ... | @@ -817,6 +1020,36 @@ impl CommitIo for File<'_> { |
| 817 | 1020 | } |
| 818 | 1021 | |
| 819 | 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. | |
| 1025 | fn 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 | ||
| 820 | 1053 | fn checked(stamp: &onestore::Stamp) -> [(u64, usize); 2] { |
| 821 | 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 | 623 | |
| 624 | 624 | /// Notebook structure over the share: sections and groups created, renamed, coloured, |
| 625 | 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"] | |
| 630 | fn 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 | 688 | #[test] |
| 627 | 689 | #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] |
| 628 | 690 | fn live_structure() { |
| ... | ... | @@ -729,3 +791,150 @@ fn live_structure() { |
| 729 | 791 | } |
| 730 | 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`"] | |
| 799 | fn 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] | |
| 843 | fn 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)"] | |
| 880 | fn 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 | ||
| 892 | fn corpus(path: &str) -> Vec<u8> { | |
| 893 | fs::read(format!( | |
| 894 | "{}/../../corpus/{path}", | |
| 895 | env!("CARGO_MANIFEST_DIR") | |
| 896 | )) | |
| 897 | .unwrap() | |
| 898 | } | |
| 899 | ||
| 900 | fn 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] | |
| 910 | fn 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] | |
| 919 | fn 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] | |
| 927 | fn 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 | 1 | use super::*; |
| 2 | use crate::working::{Request, Sealed}; | |
| 2 | use crate::{ | |
| 3 | resolve::{Merged, Version}, | |
| 4 | working::{Request, Sealed}, | |
| 5 | }; | |
| 3 | 6 | use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction}; |
| 4 | 7 | use rusqlite::OptionalExtension; |
| 5 | 8 | use std::{ |
| ... | ... | @@ -18,6 +21,25 @@ pub trait Remote { |
| 18 | 21 | fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError>; |
| 19 | 22 | /// Confirms that the file still has `base`'s stamp and is durable (`onestore::confirm`). |
| 20 | 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 | } |
| 22 | 44 | |
| 23 | 45 | #[derive(Debug, Clone, PartialEq, Eq)] |
| ... | ... | @@ -156,8 +178,26 @@ impl Replica { |
| 156 | 178 | /// changed. Reads the remote image only when its stamp moved; network I/O holds |
| 157 | 179 | /// synchronization ownership without holding the cache mutex. Uncertain attempts are |
| 158 | 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 | 183 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> { |
| 160 | 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 | 201 | let state = state(&*self.lock()?)?; |
| 162 | 202 | let observed = remote.stamp().map_err(Error::RemoteIo)?; |
| 163 | 203 | if let Some(blocked) = &state.blocked |
| ... | ... | @@ -482,7 +522,8 @@ impl Replica { |
| 482 | 522 | None if !state.queued => state.base, |
| 483 | 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 | } |
| 488 | 529 |
crates/notebook/src/worker.rs+56-1| ... | ... | @@ -3,7 +3,7 @@ use std::{ |
| 3 | 3 | collections::hash_map::RandomState, |
| 4 | 4 | hash::BuildHasher, |
| 5 | 5 | sync::{ |
| 6 | Arc, | |
| 6 | Arc, Mutex, | |
| 7 | 7 | atomic::{AtomicBool, AtomicU64, Ordering}, |
| 8 | 8 | mpsc::{self, SyncSender}, |
| 9 | 9 | }, |
| ... | ... | @@ -21,9 +21,21 @@ pub(super) struct Signal { |
| 21 | 21 | /// A watch on the file's folder wakes the worker when the file changes, so an idle worker |
| 22 | 22 | /// waits for that instead of checking the file on its own (`Background::hold`). |
| 23 | 23 | pub(crate) watched: AtomicBool, |
| 24 | pause: Mutex<Pause>, | |
| 24 | 25 | sender: SyncSender<()>, |
| 25 | 26 | } |
| 26 | 27 | |
| 28 | /// How local edits wait before they publish (`SyncWorker::set_pause`). | |
| 29 | #[derive(Default)] | |
| 30 | struct 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. | |
| 37 | const LONGEST: Duration = Duration::from_secs(30); | |
| 38 | ||
| 27 | 39 | impl Signal { |
| 28 | 40 | pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) { |
| 29 | 41 | let (sender, receiver) = mpsc::sync_channel(1); |
| ... | ... | @@ -33,6 +45,7 @@ impl Signal { |
| 33 | 45 | synced: AtomicU64::new(0), |
| 34 | 46 | requested: AtomicBool::new(false), |
| 35 | 47 | watched: AtomicBool::new(false), |
| 48 | pause: Mutex::default(), | |
| 36 | 49 | sender, |
| 37 | 50 | }; |
| 38 | 51 | (Arc::new(signal), receiver) |
| ... | ... | @@ -42,6 +55,28 @@ impl Signal { |
| 42 | 55 | // One retained notification covers edits that arrive during network I/O. |
| 43 | 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 | } |
| 46 | 81 | |
| 47 | 82 | /// Owns automatic reconciliation. Dropping requests cancellation without blocking. |
| ... | ... | @@ -65,6 +100,16 @@ impl SyncWorker { |
| 65 | 100 | Some(self.signal.synced.load(Ordering::Acquire)).filter(|time| *time != 0) |
| 66 | 101 | } |
| 67 | 102 | |
| 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 | 113 | /// Working offline, the worker neither connects nor steps: local edits stay queued |
| 69 | 114 | /// until `wake`, or until working online again. |
| 70 | 115 | pub fn set_offline(&self, offline: bool) { |
| ... | ... | @@ -157,6 +202,16 @@ impl Replica { |
| 157 | 202 | let _ = receiver.recv(); |
| 158 | 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 | 215 | let result = match remote.as_mut() { |
| 161 | 216 | Some(remote) => { |
| 162 | 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 | 543 | for (edit, id) in accepted.drain(..).zip(ids) { |
| 544 | 544 | (edit.reply)(Ok(id)); |
| 545 | 545 | } |
| 546 | crate::wake(worker); | |
| 546 | crate::edited(worker); | |
| 547 | 547 | } |
| 548 | 548 | Err(error) => { |
| 549 | 549 | let message = error.to_string(); |
| ... | ... | @@ -679,6 +679,7 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 679 | 679 | author, |
| 680 | 680 | objects, |
| 681 | 681 | crate::now(), |
| 682 | None, | |
| 682 | 683 | )?; |
| 683 | 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"] | |
| 10 | mod sweep; | |
| 11 | ||
| 12 | use notebook::{Remote, Replica, Version}; | |
| 13 | use 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 | }; | |
| 22 | use std::{collections::BTreeSet, io}; | |
| 23 | ||
| 24 | /// The file as the server holds it, and the versions it keeps beside it. | |
| 25 | struct 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 | ||
| 35 | impl 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. | |
| 43 | struct 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 | ||
| 53 | struct 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. | |
| 60 | struct Local<'a> { | |
| 61 | copy: &'a mut Copy, | |
| 62 | server: &'a mut Server, | |
| 63 | } | |
| 64 | ||
| 65 | fn refused() -> CommitError { | |
| 66 | CommitError { | |
| 67 | state: CommitState::NotCommitted, | |
| 68 | error: io::Error::from(io::ErrorKind::WouldBlock), | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | impl 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. | |
| 133 | struct Random(u64); | |
| 134 | ||
| 135 | impl 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 | ||
| 148 | fn 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 | ||
| 157 | fn 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`. | |
| 178 | fn 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. | |
| 198 | fn 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 | ||
| 223 | fn 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. | |
| 236 | fn 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)] | |
| 253 | struct 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 | ||
| 260 | impl 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 | ||
| 445 | fn 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. | |
| 453 | fn 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. | |
| 481 | fn 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] | |
| 592 | fn 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] | |
| 599 | fn 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] | |
| 701 | fn 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`). | |
| 836 | fn 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 | use notebook::discover::{Cache, Entry, Error, Limits, Local, Source, discover}; | |
| 1 | use notebook::discover::{Cache, Entry, Error, Limits, Local, Reason, Source, discover}; | |
| 2 | 2 | use std::{fs, io, path::Path}; |
| 3 | 3 | |
| 4 | 4 | fn limits() -> Limits { |
| ... | ... | @@ -66,22 +66,138 @@ fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() { |
| 66 | 66 | ); |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /// Sets `path`'s modification time to `seconds` after the epoch. | |
| 70 | fn 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 | ||
| 79 | fn 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 | 89 | #[test] |
| 70 | fn copied_identities_are_ambiguous_even_across_different_groups() { | |
| 90 | #[ignore = "expects copies set aside; duplicates now open as sections"] | |
| 91 | fn a_copied_section_lists_as_a_copy_of_the_one_the_toc_names() { | |
| 71 | 92 | let root = tempfile::tempdir().unwrap(); |
| 72 | 93 | fixture(root.path()); |
| 73 | 94 | fs::create_dir(root.path().join("group")).unwrap(); |
| 74 | fs::copy( | |
| 75 | root.path().join("one.one"), | |
| 76 | root.path().join("group/other.one"), | |
| 95 | for copy in ["one 2.one", "group/other.one"] { | |
| 96 | fs::copy(root.path().join("one.one"), root.path().join(copy)).unwrap(); | |
| 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"] | |
| 120 | fn 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] | |
| 135 | fn 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 | 148 | .unwrap(); |
| 79 | assert!( | |
| 80 | matches!(discover(&mut Local::open(root.path()).unwrap(), limits()), | |
| 81 | Err(Error::DuplicateIdentity { first, second, .. }) | |
| 82 | if [first.as_str(), second.as_str()].contains(&"one.one") | |
| 83 | && [first.as_str(), second.as_str()].contains(&"group/other.one")) | |
| 84 | ); | |
| 149 | let copy = root.path().join("Group 2"); | |
| 150 | fs::create_dir(&copy).unwrap(); | |
| 151 | for name in ["Inner.one", "Open Notebook.onetoc2"] { | |
| 152 | fs::copy(group.join(name), copy.join(name)).unwrap(); | |
| 153 | touch(&group.join(name), 1_000_000_000); | |
| 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] | |
| 169 | fn 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 | } |
| 86 | 202 | |
| 87 | 203 | #[test] |
| ... | ... | @@ -194,10 +310,80 @@ fn limits_and_incomplete_files_do_not_produce_a_catalog() { |
| 194 | 310 | ), |
| 195 | 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 | 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] | |
| 320 | fn 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 | } |
| 202 | 388 | |
| 203 | 389 | #[test] |
| ... | ... | @@ -432,7 +618,7 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() { |
| 432 | 618 | assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap()); |
| 433 | 619 | |
| 434 | 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 | 622 | assert!(kept.discover(&mut source, limits()).is_err()); |
| 437 | 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 | ||
| 3 | use notebook::session::{Notebook, Section}; | |
| 4 | use onestore::{ | |
| 5 | ExGuid, | |
| 6 | op::{Edit, Op, PageOp}, | |
| 7 | page::{Page, PageObject}, | |
| 8 | }; | |
| 9 | use std::{ | |
| 10 | path::{Path, PathBuf}, | |
| 11 | time::{Duration, Instant}, | |
| 12 | }; | |
| 13 | ||
| 14 | /// A notebook folder `name` in `directory` holding `First.one` and `Second.one`. | |
| 15 | fn 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. | |
| 30 | fn 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. | |
| 39 | fn 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. | |
| 53 | fn 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 | ||
| 76 | fn 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 | ||
| 82 | fn 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. | |
| 92 | fn 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. | |
| 103 | fn 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] | |
| 115 | fn 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] | |
| 152 | fn 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] | |
| 171 | fn 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] | |
| 183 | fn 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] | |
| 213 | fn 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"] | |
| 230 | fn 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 | 205 | section.close().unwrap(); |
| 206 | 206 | assert_eq!( |
| 207 | 207 | notebook::Replica::open( |
| 208 | std::fs::read_dir(&cache) | |
| 209 | .unwrap() | |
| 210 | .next() | |
| 211 | .unwrap() | |
| 212 | .unwrap() | |
| 213 | .path() | |
| 208 | walkdir(&cache) | |
| 209 | .into_iter() | |
| 210 | .find(|path| path.extension().is_some_and(|ext| ext == "sqlite")) | |
| 211 | .expect("the section's replica") | |
| 214 | 212 | ) |
| 215 | 213 | .unwrap() |
| 216 | 214 | .status(id) |
| ... | ... | @@ -386,11 +384,13 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() { |
| 386 | 384 | section.close().unwrap(); |
| 387 | 385 | } |
| 388 | 386 | |
| 389 | /// The replicas in `cache`, beside the folder of catalog listings. | |
| 387 | /// The replicas in `cache`, in the folder of each location. | |
| 390 | 388 | fn replicas(cache: &Path) -> usize { |
| 391 | std::fs::read_dir(cache) | |
| 392 | .unwrap() | |
| 393 | .filter(|entry| entry.as_ref().unwrap().path().is_file()) | |
| 389 | std::fs::read_dir(cache.join("replicas")) | |
| 390 | .into_iter() | |
| 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 | 394 | .count() |
| 395 | 395 | } |
| 396 | 396 | |
| ... | ... | @@ -1200,8 +1200,17 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { |
| 1200 | 1200 | !notebook.replica_path("First.one").unwrap().exists() |
| 1201 | 1201 | }); |
| 1202 | 1202 | assert!(second.exists(), "a copy with edits waiting stays"); |
| 1203 | let kept = notebook::Replica::open(&second).unwrap(); | |
| 1204 | assert_eq!(kept.pending().unwrap().len(), 1); | |
| 1203 | // The stopping thread may still be peeking at it, as a host opening a section waits out. | |
| 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 | } |
| 1206 | 1215 | |
| 1207 | 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 | 1377 | let rebased = notebook::Replica::open(&replica).unwrap(); |
| 1369 | 1378 | assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space)); |
| 1370 | 1379 | } |
| 1380 | ||
| 1381 | /// Every file under `dir`, depth first. | |
| 1382 | fn 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 | 46 | | `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse | |
| 47 | 47 | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | |
| 48 | 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 | 50 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | |
| 51 | 51 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 52 | 52 | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | |
| ... | ... | @@ -62,6 +62,7 @@ harness also accepts `--client-profile release`. |
| 62 | 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 | 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 | 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 | 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 | 67 | | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | |
| 67 | 68 | | `read_file` | Read a snapshot under whole-file exclusion | |
| ... | ... | @@ -87,7 +88,7 @@ rejects protected sections. |
| 87 | 88 | `PageCreation::new` appends, or inserts before the first page space of an existing |
| 88 | 89 | series. `Some("")` creates an empty title field; `None` omits the title node. |
| 89 | 90 | `dated(date, time)` gives the title OneNote 2010's date and time fields showing that |
| 90 | text, as OneNote titles a new page; `keeping(identity, created)` keeps another page's | |
| 91 | text, 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 | |
| 91 | 92 | identity and creation time, as a page moved to the recycle bin keeps them. The page has |
| 92 | 93 | no body outlines or applied template; `create_empty_section` makes a section for such |
| 93 | 94 | pages, 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 @@ |
| 1 | use onestore::{RevisionIndex, Store, document::{Document, Kind}}; | |
| 2 | fn 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 | } | |
| 8 | fn 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 | } | |
| 14 | fn 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 | 239 | values.push((0x08001d13 | (1 << 31), Vec::new())); |
| 240 | 240 | } |
| 241 | 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 | 250 | let mut changed = BTreeMap::new(); |
| 244 | 251 | changed.insert( |
| 245 | 252 | file, |
| ... | ... | @@ -555,7 +562,8 @@ fn write_strokes( |
| 555 | 562 | } |
| 556 | 563 | |
| 557 | 564 | /// 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 | 567 | #[derive(PartialEq)] |
| 560 | 568 | struct InkPen { |
| 561 | 569 | width: u32, |
| ... | ... | @@ -565,6 +573,7 @@ struct InkPen { |
| 565 | 573 | pen_tip: Option<u8>, |
| 566 | 574 | raster_operation: Option<u8>, |
| 567 | 575 | shape: bool, |
| 576 | pressure: bool, | |
| 568 | 577 | } |
| 569 | 578 | |
| 570 | 579 | impl InkPen { |
| ... | ... | @@ -577,17 +586,22 @@ impl InkPen { |
| 577 | 586 | pen_tip: stroke.pen_tip, |
| 578 | 587 | raster_operation: stroke.raster_operation, |
| 579 | 588 | shape, |
| 589 | pressure: !stroke.pressure.is_empty(), | |
| 580 | 590 | } |
| 581 | 591 | } |
| 582 | 592 | |
| 583 | 593 | fn values(&self) -> Values { |
| 584 | let dimensions = if self.shape { | |
| 594 | let mut dimensions = if self.shape { | |
| 585 | 595 | crate::page::ink::SHAPE_DIMENSIONS |
| 586 | 596 | } else { |
| 587 | 597 | crate::page::ink::DIMENSIONS |
| 588 | }; | |
| 598 | } | |
| 599 | .to_vec(); | |
| 600 | if self.pressure { | |
| 601 | dimensions.extend(crate::page::ink::PRESSURE_DIMENSION); | |
| 602 | } | |
| 589 | 603 | let mut values: Values = vec![ |
| 590 | (0x1c00340a, dimensions.to_vec()), | |
| 604 | (0x1c00340a, dimensions), | |
| 591 | 605 | (0x1400340c, self.height.to_le_bytes().to_vec()), |
| 592 | 606 | (0x1400340d, self.width.to_le_bytes().to_vec()), |
| 593 | 607 | ]; |
| ... | ... | @@ -603,7 +617,7 @@ impl InkPen { |
| 603 | 617 | if let Some(operation) = self.raster_operation { |
| 604 | 618 | values.push((0x0c003413, vec![operation])); |
| 605 | 619 | } |
| 606 | if !self.shape { | |
| 620 | if !self.shape && !self.pressure { | |
| 607 | 621 | values.push((0x88003411, Vec::new())); |
| 608 | 622 | } |
| 609 | 623 | values |
| ... | ... | @@ -626,6 +640,14 @@ fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> { |
| 626 | 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 | 651 | Ok(vec![(0x1c00340b, stroke.packet())]) |
| 630 | 652 | } |
| 631 | 653 |
crates/onestore/src/op/lower.rs+11-7| ... | ... | @@ -328,11 +328,8 @@ pub(crate) fn validate( |
| 328 | 328 | } |
| 329 | 329 | } |
| 330 | 330 | (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { |
| 331 | if table.id != previous.id | |
| 332 | || table.layout != previous.layout | |
| 333 | || table.tags != previous.tags | |
| 334 | { | |
| 335 | return Err(invalid("Table identity, layout and tags cannot be edited")); | |
| 331 | if table.id != previous.id || table.layout != previous.layout { | |
| 332 | return Err(invalid("Table identity and layout cannot be edited")); | |
| 336 | 333 | } |
| 337 | 334 | let cells: BTreeMap<ExGuid, &TableCell> = previous |
| 338 | 335 | .rows |
| ... | ... | @@ -747,6 +744,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { |
| 747 | 744 | match &mut stripped.content { |
| 748 | 745 | ParagraphContent::Text(text) => text.tags.clear(), |
| 749 | 746 | ParagraphContent::Table(table) => { |
| 747 | table.tags.clear(); | |
| 750 | 748 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { |
| 751 | 749 | cell.paragraphs = cell.paragraphs.iter().map(bare).collect(); |
| 752 | 750 | } |
| ... | ... | @@ -1766,8 +1764,14 @@ impl Lowering { |
| 1766 | 1764 | .ok_or_else(|| invalid("A paragraph is missing after text edits"))? |
| 1767 | 1765 | .clone(); |
| 1768 | 1766 | let mut targets = vec![(*id, &paragraph.tags, current.tags.clone())]; |
| 1769 | if let (Some(text), Some(stored)) = (paragraph.text(), current.text()) { | |
| 1770 | targets.push((text.id, &text.tags, stored.tags.clone())); | |
| 1767 | match (&paragraph.content, &current.content) { | |
| 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 | 1776 | for (target, tags, stored) in targets { |
| 1773 | 1777 | if same_tags(tags, &stored) { |
crates/onestore/src/op/model.rs+8-1| ... | ... | @@ -450,7 +450,8 @@ fn inserted(text: &Paragraph) -> Paragraph { |
| 450 | 450 | })) |
| 451 | 451 | } |
| 452 | 452 | |
| 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. | |
| 454 | 455 | fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { |
| 455 | 456 | let himetric = |points: f32| points * 2540.0 / 72.0 * 72.0 / 2540.0; |
| 456 | 457 | crate::page::InkStroke { |
| ... | ... | @@ -461,6 +462,12 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { |
| 461 | 462 | .collect(), |
| 462 | 463 | width: himetric(stroke.width), |
| 463 | 464 | height: himetric(stroke.height), |
| 465 | pressure: stroke | |
| 466 | .pressure | |
| 467 | .iter() | |
| 468 | .copied() | |
| 469 | .map(crate::page::ink::level) | |
| 470 | .collect(), | |
| 464 | 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 | 1099 | layout: Layout::default(), |
| 1100 | 1100 | size: Some([0.75, 0.75]), |
| 1101 | 1101 | bytes: Some(PNG.into()), |
| 1102 | display: None, | |
| 1102 | 1103 | alt: Some("dot".into()), |
| 1103 | 1104 | background: false, |
| 1105 | printout: None, | |
| 1104 | 1106 | }), |
| 1105 | 1107 | Family::Attachment => ParagraphContent::Attachment(Attachment { |
| 1106 | 1108 | id: id(), |
| ... | ... | @@ -1152,8 +1154,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1152 | 1154 | }, |
| 1153 | 1155 | size: Some([0.75, 0.75]), |
| 1154 | 1156 | bytes: Some(PNG.into()), |
| 1157 | display: None, | |
| 1155 | 1158 | alt: None, |
| 1156 | 1159 | background: false, |
| 1160 | printout: None, | |
| 1157 | 1161 | }) |
| 1158 | 1162 | } else { |
| 1159 | 1163 | PageObject::Ink(Ink { |
| ... | ... | @@ -1254,8 +1258,13 @@ fn stroke(rng: &mut Rng) -> InkStroke { |
| 1254 | 1258 | color: Some(0x0000ff), |
| 1255 | 1259 | transparency: None, |
| 1256 | 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 | 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 | } |
| 1261 | 1270 |
crates/onestore/src/page/ink.rs+75-11| ... | ... | @@ -23,6 +23,14 @@ const DIMENSION_X: [u8; 16] = [ |
| 23 | 23 | const DIMENSION_Y: [u8; 16] = [ |
| 24 | 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. | |
| 27 | const 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. | |
| 31 | pub fn level(pressure: f32) -> f32 { | |
| 32 | (pressure.clamp(0.0, 1.0) * PRESSURE_LEVELS).round() / PRESSURE_LEVELS | |
| 33 | } | |
| 26 | 34 | |
| 27 | 35 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 28 | 36 | pub struct Ink { |
| ... | ... | @@ -120,6 +128,7 @@ impl Ink { |
| 120 | 128 | strokes.push(InkStroke { |
| 121 | 129 | id: super::text::new_id()?, |
| 122 | 130 | points: path.into_iter().map(|p| p.map(snap)).collect(), |
| 131 | pressure: Vec::new(), | |
| 123 | 132 | ..pen.clone() |
| 124 | 133 | }); |
| 125 | 134 | } |
| ... | ... | @@ -165,6 +174,10 @@ pub struct InkStroke { |
| 165 | 174 | /// what lies beneath. |
| 166 | 175 | #[serde(default)] |
| 167 | 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 | } |
| 169 | 182 | |
| 170 | 183 | impl Ink { |
| ... | ... | @@ -273,6 +286,7 @@ impl InkStroke { |
| 273 | 286 | transparency, |
| 274 | 287 | pen_tip, |
| 275 | 288 | raster_operation, |
| 289 | ignore_pressure, | |
| 276 | 290 | .. |
| 277 | 291 | } = &style.kind |
| 278 | 292 | else { |
| ... | ... | @@ -281,16 +295,16 @@ impl InkStroke { |
| 281 | 295 | if dimensions.len() % 32 != 0 || dimensions.is_empty() { |
| 282 | 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(); | |
| 285 | let axis = |guid: [u8; 16]| guids.iter().position(|g| **g == guid); | |
| 286 | let (Some(x), Some(y)) = (axis(DIMENSION_X), axis(DIMENSION_Y)) else { | |
| 298 | let entries: Vec<&[u8]> = dimensions.chunks_exact(32).collect(); | |
| 299 | let axis = |guid: &[u8]| entries.iter().position(|entry| entry[..16] == *guid); | |
| 300 | let (Some(x), Some(y)) = (axis(&DIMENSION_X), axis(&DIMENSION_Y)) else { | |
| 287 | 301 | return Err(invalid("Ink dimension table lacks X and Y")); |
| 288 | 302 | }; |
| 289 | 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 | 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 | 308 | let coordinates = |dimension: usize, factor: f32| { |
| 295 | 309 | let mut position = 0i64; |
| 296 | 310 | values[dimension * per_dimension..(dimension + 1) * per_dimension] |
| ... | ... | @@ -304,6 +318,25 @@ impl InkStroke { |
| 304 | 318 | .zip(coordinates(y, scale[1])) |
| 305 | 319 | .map(|(x, y)| [x, y]) |
| 306 | 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 | 340 | Ok(Self { |
| 308 | 341 | id, |
| 309 | 342 | points, |
| ... | ... | @@ -313,8 +346,18 @@ impl InkStroke { |
| 313 | 346 | transparency: *transparency, |
| 314 | 347 | pen_tip: *pen_tip, |
| 315 | 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 | } |
| 319 | 362 | |
| 320 | 363 | impl InkShape { |
| ... | ... | @@ -421,19 +464,32 @@ fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> { |
| 421 | 464 | } |
| 422 | 465 | |
| 423 | 466 | impl InkStroke { |
| 424 | /// The stroke packet OneNote stores: X then Y as ISF multi-byte first differences of the | |
| 425 | /// points rounded to HIMETRIC. | |
| 467 | /// The stroke packet OneNote stores: X, Y and any pressure as ISF multi-byte first | |
| 468 | /// differences, of the points rounded to HIMETRIC and the pressure's levels. | |
| 426 | 469 | pub(crate) fn packet(&self) -> Vec<u8> { |
| 427 | let axis = |index: usize| { | |
| 470 | let differences = |values: Vec<i64>| { | |
| 428 | 471 | let mut previous = 0i64; |
| 429 | self.points.iter().map(move |point| { | |
| 430 | let value = (point[index] * 2540.0 / 72.0).round() as i64; | |
| 472 | values.into_iter().map(move |value| { | |
| 431 | 473 | let delta = value - previous; |
| 432 | 474 | previous = value; |
| 433 | 475 | 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 | 493 | let mut out = Vec::new(); |
| 438 | 494 | let mut push = |raw: u64| { |
| 439 | 495 | let mut raw = raw; |
| ... | ... | @@ -464,6 +520,13 @@ pub(crate) const DIMENSIONS: [u8; 64] = [ |
| 464 | 520 | 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41, |
| 465 | 521 | ]; |
| 466 | 522 | |
| 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`). | |
| 525 | pub(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 | 530 | /// The dimension table OneNote 2010 writes for a drawn shape's pen: X and Y in HIMETRIC over |
| 468 | 531 | /// the whole 32-bit range, at a resolution of 1000. |
| 469 | 532 | pub(crate) const SHAPE_DIMENSIONS: [u8; 64] = [ |
| ... | ... | @@ -488,6 +551,7 @@ mod tests { |
| 488 | 551 | transparency: None, |
| 489 | 552 | pen_tip: None, |
| 490 | 553 | raster_operation: None, |
| 554 | pressure: Vec::new(), | |
| 491 | 555 | }; |
| 492 | 556 | let values = multi_byte(&stroke.packet()).unwrap(); |
| 493 | 557 | assert_eq!(values.len(), 6); |
crates/onestore/src/page/mod.rs+34-5| ... | ... | @@ -429,8 +429,16 @@ pub struct Image { |
| 429 | 429 | pub size: Option<[f32; 2]>, |
| 430 | 430 | #[serde(with = "payload")] |
| 431 | 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 | 436 | pub alt: Option<String>, |
| 433 | 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 | } |
| 435 | 443 | |
| 436 | 444 | impl PartialEq for Image { |
| ... | ... | @@ -440,6 +448,7 @@ impl PartialEq for Image { |
| 440 | 448 | && self.size == other.size |
| 441 | 449 | && self.alt == other.alt |
| 442 | 450 | && self.background == other.background |
| 451 | && self.printout == other.printout | |
| 443 | 452 | } |
| 444 | 453 | } |
| 445 | 454 | |
| ... | ... | @@ -454,6 +463,7 @@ impl Image { |
| 454 | 463 | container, |
| 455 | 464 | alt, |
| 456 | 465 | background, |
| 466 | printout, | |
| 457 | 467 | picture_width, |
| 458 | 468 | picture_height, |
| 459 | 469 | .. |
| ... | ... | @@ -461,25 +471,44 @@ impl Image { |
| 461 | 471 | else { |
| 462 | 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 | 478 | let data = revision |
| 466 | 479 | .nodes |
| 467 | 480 | .get(container) |
| 468 | 481 | .ok_or_else(|| invalid("Missing canvas image data"))?; |
| 469 | 482 | match &data.kind { |
| 470 | Kind::File { payload, .. } => payload.map(Arc::from), | |
| 471 | _ => return Err(invalid("Canvas image data has the wrong type")), | |
| 483 | Kind::File { payload, .. } => Ok(payload.map(Arc::from)), | |
| 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 | 493 | Ok(Self { |
| 477 | 494 | id, |
| 478 | 495 | layout: node.layout.clone(), |
| 479 | 496 | size: picture_width.zip(*picture_height).map(|(w, h)| [w, h]), |
| 480 | 497 | bytes, |
| 498 | display: payload(web)?, | |
| 481 | 499 | alt: alt.clone(), |
| 482 | 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 | 553 | Ok(self) |
| 554 | 554 | } |
| 555 | 555 | |
| 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 | 566 | /// Who creates the page. |
| 557 | 567 | pub fn author(&self) -> &str { |
| 558 | 568 | &self.author |
crates/onestore/src/paragraph.rs+9-2| ... | ... | @@ -119,7 +119,7 @@ impl ParagraphSplit { |
| 119 | 119 | } |
| 120 | 120 | } else if let Some(after) = runs |
| 121 | 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 | 123 | && link(after - 1) |
| 124 | 124 | && link(after) |
| 125 | 125 | && !resolved[after].text.starts_with('\u{fddf}') |
| ... | ... | @@ -142,7 +142,14 @@ impl ParagraphSplit { |
| 142 | 142 | guid: self.guid, |
| 143 | 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 | 153 | // Enter at the start leaves the emptied upper paragraph the first run's style, as |
| 147 | 154 | // OneNote does (`evidence/structural-edits/xml/c2s-*`). |
| 148 | 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 | 34 | /// The in-place writes of the sealed transactions, in order. |
| 35 | 35 | patches: Vec<(u64, Vec<u8>)>, |
| 36 | 36 | state: StoreState, |
| 37 | /// Payloads the opened image embeds, by identity. | |
| 38 | files: &'a [([u8; 16], Chunk)], | |
| 37 | /// Payloads the opened images embed, by identity, with the image holding each. | |
| 38 | files: &'a [([u8; 16], Chunk, &'a [u8])], | |
| 39 | 39 | /// Payload identities the file-data store declares. |
| 40 | 40 | declared: BTreeSet<[u8; 16]>, |
| 41 | 41 | spaces: BTreeMap<ExGuid, Space<'a>>, |
| ... | ... | @@ -139,74 +139,125 @@ struct Open<'a> { |
| 139 | 139 | impl<'a> Section<'a> { |
| 140 | 140 | /// Parses and fully validates a section image. |
| 141 | 141 | pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> { |
| 142 | let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); | |
| 143 | drop(image); | |
| 144 | let store = Store::parse(bytes)?; | |
| 145 | if let Some(offset) = store.checksum_mismatches.first() { | |
| 146 | return Err(Error { | |
| 147 | offset: *offset, | |
| 148 | message: "Cannot write a file with transaction checksum damage", | |
| 149 | }); | |
| 150 | } | |
| 151 | if store.header.file_type != FileType::Section { | |
| 142 | Self::open_with(arena, vec![image], |_, space| { | |
| 143 | space.labels.get(&(ExGuid::default(), 1)).copied() | |
| 144 | }) | |
| 145 | } | |
| 146 | ||
| 147 | /// Opens an earlier state of a section: each object space in `revisions` at the revision | |
| 148 | /// given, taken from the first of `images` that stores it, and no other space; the first | |
| 149 | /// image is the section's. For reading, as a merge reads the state two copies of a section | |
| 150 | /// last shared, which may lie partly in each copy. | |
| 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 | 163 | return Err(Error { |
| 153 | 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)?; | |
| 158 | index.validate_current()?; | |
| 168 | Ok(section) | |
| 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 | 180 | let mut spaces = BTreeMap::new(); |
| 160 | for (id, space) in &index.spaces { | |
| 161 | let Some(rid) = space.labels.get(&(ExGuid::default(), 1)).copied() else { | |
| 162 | continue; | |
| 163 | }; | |
| 164 | let revision = bound(bytes, index.resolve(*id, rid)?); | |
| 165 | let history = space | |
| 166 | .labels | |
| 167 | .get(&(versions::HISTORY, 1)) | |
| 168 | .map(|history| -> Result<History<'a>> { | |
| 169 | Ok(History { | |
| 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; | |
| 181 | let mut files: Vec<([u8; 16], Chunk, &'a [u8])> = Vec::new(); | |
| 182 | for image in images { | |
| 183 | let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); | |
| 184 | drop(image); | |
| 185 | let store = Store::parse(bytes)?; | |
| 186 | if let Some(offset) = store.checksum_mismatches.first() { | |
| 187 | return Err(Error { | |
| 188 | offset: *offset, | |
| 189 | message: "Cannot write a file with transaction checksum damage", | |
| 190 | }); | |
| 187 | 191 | } |
| 188 | let (Some(guid), Some(Reference::Data(chunk))) = | |
| 189 | (node.payload.first_chunk(), node.reference) | |
| 190 | else { | |
| 192 | if store.header.file_type != FileType::Section { | |
| 191 | 193 | return Err(Error { |
| 192 | offset: node.offset, | |
| 193 | message: "File-data object lacks a data reference", | |
| 194 | offset: 0, | |
| 195 | message: "Choose a section file", | |
| 194 | 196 | }); |
| 195 | }; | |
| 196 | files.push((*guid, chunk)); | |
| 197 | } | |
| 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 | 250 | Ok(Self { |
| 200 | 251 | arena, |
| 201 | 252 | segments: vec![(0, bytes)], |
| 202 | 253 | patches: Vec::new(), |
| 203 | state: store.state()?, | |
| 204 | declared: files.iter().map(|(guid, _)| *guid).collect(), | |
| 254 | state, | |
| 255 | declared: files.iter().map(|(guid, ..)| *guid).collect(), | |
| 205 | 256 | files: arena.0.alloc_slice_copy(&files), |
| 206 | 257 | spaces, |
| 207 | 258 | payloads: Vec::new(), |
| 208 | 259 | broken: false, |
| 209 | root: index.root, | |
| 260 | root, | |
| 210 | 261 | undo: None, |
| 211 | 262 | }) |
| 212 | 263 | } |
| ... | ... | @@ -426,12 +477,12 @@ impl<'a> Section<'a> { |
| 426 | 477 | |
| 427 | 478 | /// Reads a payload of the opened image by identity. |
| 428 | 479 | fn files(&self) -> Files<'a> { |
| 429 | let (bytes, files) = (self.segments[0].1, self.files); | |
| 480 | let files = self.files; | |
| 430 | 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 | 483 | match files[start..] |
| 433 | 484 | .iter() |
| 434 | .take_while(|(id, _)| *id == guid) | |
| 485 | .take_while(|(id, ..)| *id == guid) | |
| 435 | 486 | .count() |
| 436 | 487 | { |
| 437 | 488 | 0 => Err(Error { |
| ... | ... | @@ -439,7 +490,7 @@ impl<'a> Section<'a> { |
| 439 | 490 | message: "File-data object is not declared", |
| 440 | 491 | }), |
| 441 | 492 | 1 => { |
| 442 | let chunk = files[start].1; | |
| 493 | let (_, chunk, bytes) = files[start]; | |
| 443 | 494 | let (offset, length) = (chunk.offset as usize, chunk.length as usize); |
| 444 | 495 | let blob = bytes.get(offset..offset + length).ok_or(Error { |
| 445 | 496 | offset, |
| ... | ... | @@ -633,7 +684,26 @@ impl<'a> Section<'a> { |
| 633 | 684 | /// embed, as one transaction on `stamp`; none when nothing changed. An error leaves the |
| 634 | 685 | /// section to be reopened. |
| 635 | 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 | 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 | 707 | self.broken = true; |
| 638 | 708 | let arena = &self.arena.0; |
| 639 | 709 | let mut appending = Appending::new(self.state.clone()); |
| ... | ... | @@ -680,7 +750,7 @@ impl<'a> Section<'a> { |
| 680 | 750 | previous: space.restored.or(space.rid), |
| 681 | 751 | new_space: space.rid.is_none(), |
| 682 | 752 | label: (ExGuid::default(), 1), |
| 683 | rid: None, | |
| 753 | rid: names.get(id).copied(), | |
| 684 | 754 | live: file, |
| 685 | 755 | commit: &commit, |
| 686 | 756 | replaced: &replaced, |
crates/onestore/src/section/versions.rs+10-3| ... | ... | @@ -134,9 +134,16 @@ impl<'a> Section<'a> { |
| 134 | 134 | } |
| 135 | 135 | let image = self.image(); |
| 136 | 136 | let store = Store::parse(&image)?; |
| 137 | let index = RevisionIndex::parse(&store)?; | |
| 138 | let rid = self.version_revision(&index, space, context)?; | |
| 139 | let resolved = index.resolve(space, rid)?; | |
| 137 | let rid = self.version_revision(&RevisionIndex::parse(&store)?, space, context)?; | |
| 138 | self.page_at(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 | 147 | let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| { |
| 141 | 148 | store.file_data(guid) |
| 142 | 149 | })?; |
crates/onestore/src/snapshot.rs+25-17| ... | ... | @@ -23,29 +23,47 @@ pub(crate) fn read_exact( |
| 23 | 23 | |
| 24 | 24 | /// Reads a bounded snapshot; the caller must provide fresh I/O and exclude in-place maintenance. |
| 25 | 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. | |
| 26 | 27 | pub fn read_snapshot( |
| 27 | 28 | read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>, |
| 28 | 29 | limit: usize, |
| 29 | 30 | ) -> io::Result<Option<Vec<u8>>> { |
| 30 | snapshot(read, limit, |store| { | |
| 31 | RevisionIndex::parse(store)?.validate_current() | |
| 32 | }) | |
| 31 | let Some(bytes) = image(read, limit)? else { | |
| 32 | return Ok(None); | |
| 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 | } |
| 34 | 40 | |
| 35 | 41 | /// Reads stable storage and checksums without requiring traversable property references. |
| 36 | 42 | /// Used to inspect encrypted or incomplete documents; this does not establish edit readiness. |
| 37 | 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`. | |
| 38 | 45 | pub fn read_storage_snapshot( |
| 39 | 46 | read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>, |
| 40 | 47 | limit: usize, |
| 41 | 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 | } |
| 44 | 62 | |
| 45 | fn snapshot( | |
| 63 | /// The file's bytes, or `None` where its header changed or its storage ended early meanwhile. | |
| 64 | fn image( | |
| 46 | 65 | mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>, |
| 47 | 66 | limit: usize, |
| 48 | validate: impl FnOnce(&Store<'_>) -> Result<(), crate::Error>, | |
| 49 | 67 | ) -> io::Result<Option<Vec<u8>>> { |
| 50 | 68 | let mut header = [0; 1024]; |
| 51 | 69 | read_exact(&mut read, 0, &mut header)?; |
| ... | ... | @@ -82,15 +100,5 @@ fn snapshot( |
| 82 | 100 | } |
| 83 | 101 | let mut after = [0; 1024]; |
| 84 | 102 | read_exact(&mut read, 0, &mut after)?; |
| 85 | if header != after { | |
| 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)) | |
| 103 | Ok((header == after).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 | ||
| 4 | use onestore::{ | |
| 5 | Arena, ExGuid, PageCreation, RevisionIndex, Section, Store, | |
| 6 | op::{Edit, Op, PageOp, SectionOp}, | |
| 7 | }; | |
| 8 | use std::collections::BTreeMap; | |
| 9 | ||
| 10 | fn 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. | |
| 19 | fn 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. | |
| 31 | const 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] | |
| 50 | fn 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] | |
| 84 | fn 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] | |
| 94 | fn 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 | 115 | layout: Default::default(), |
| 116 | 116 | size: Some([0.75, 0.75]), |
| 117 | 117 | bytes: Some(Arc::from(PNG)), |
| 118 | display: None, | |
| 118 | 119 | alt: None, |
| 119 | 120 | background: false, |
| 121 | printout: None, | |
| 120 | 122 | }; |
| 121 | 123 | holder.content = ParagraphContent::Image(image.clone()); |
| 122 | 124 | body_paragraphs(&mut after).push(holder.clone()); |
| ... | ... | @@ -293,8 +295,10 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { |
| 293 | 295 | }, |
| 294 | 296 | size: Some([0.75, 0.75]), |
| 295 | 297 | bytes: Some(Arc::from(PNG)), |
| 298 | display: None, | |
| 296 | 299 | alt: Some("Placed in Rust".into()), |
| 297 | 300 | background: false, |
| 301 | printout: None, | |
| 298 | 302 | }; |
| 299 | 303 | after.objects.push(PageObject::Image(image.clone())); |
| 300 | 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 | 362 | layout: Default::default(), |
| 359 | 363 | size: None, |
| 360 | 364 | bytes: Some(Arc::from(b"not a picture".as_slice())), |
| 365 | display: None, | |
| 361 | 366 | alt: None, |
| 362 | 367 | background: false, |
| 368 | printout: None, | |
| 363 | 369 | }); |
| 364 | 370 | body_paragraphs(&mut after).push(holder); |
| 365 | 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 | 89 | transparency: None, |
| 90 | 90 | pen_tip: None, |
| 91 | 91 | raster_operation: None, |
| 92 | pressure: Vec::new(), | |
| 92 | 93 | } |
| 93 | 94 | } |
| 94 | 95 | |
| ... | ... | @@ -391,6 +392,7 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) { |
| 391 | 392 | transparency: None, |
| 392 | 393 | pen_tip: None, |
| 393 | 394 | raster_operation: None, |
| 395 | pressure: Vec::new(), | |
| 394 | 396 | }; |
| 395 | 397 | let marker = InkStroke { |
| 396 | 398 | width: himetric(70.0), |
| ... | ... | @@ -631,3 +633,164 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() { |
| 631 | 633 | } |
| 632 | 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`). | |
| 638 | const PRESSURE: &[u8] = include_bytes!("../../../corpus/ink-pressure/native/notebook/Pressure.one"); | |
| 639 | ||
| 640 | #[test] | |
| 641 | fn 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] | |
| 675 | fn 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 | 83 | let mut broken = source.clone(); |
| 84 | 84 | let offset = usize::try_from(Store::parse(&source).unwrap().header.root.offset).unwrap(); |
| 85 | 85 | broken[offset] ^= 1; |
| 86 | assert!( | |
| 86 | assert_eq!( | |
| 87 | 87 | onestore::read_storage_snapshot( |
| 88 | 88 | |offset, output| { |
| 89 | 89 | let offset = usize::try_from(offset).unwrap(); |
| ... | ... | @@ -93,8 +93,9 @@ fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness() |
| 93 | 93 | }, |
| 94 | 94 | broken.len() |
| 95 | 95 | ) |
| 96 | .unwrap() | |
| 97 | .is_none() | |
| 96 | .unwrap_err() | |
| 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 | 26 | winit = "0.30.13" |
| 27 | 27 | arboard = { version = "3.6.1", default-features = false } |
| 28 | 28 | accesskit = "0.25.0" |
| 29 | accesskit_consumer = "0.39.0" | |
| 29 | 30 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } |
| 31 | # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. | |
| 32 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } | |
| 33 | ring = "0.17" | |
| 30 | 34 | |
| 31 | 35 | [target.'cfg(target_os = "macos")'.dependencies] |
| 32 | 36 | libc = "0.2" |
| 33 | 37 | objc2 = "0.5.2" |
| 34 | 38 | objc2-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"] } |
| 35 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } | |
| 39 | objc2-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"] } | |
| 40 | block2 = "0.5.1" | |
| 36 | 41 | |
| 37 | 42 | [target.'cfg(target_os = "linux")'.dependencies] |
| 38 | 43 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } |
| ... | ... | @@ -43,3 +48,7 @@ libc = "0.2" |
| 43 | 48 | zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] } |
| 44 | 49 | # The colours of winit's own window frame, which the toolbar continues. |
| 45 | 50 | sctk-adwaita = { version = "0.10.1", default-features = false } |
| 51 | # Sets the window's icon through xdg-toplevel-icon on winit's own Wayland connection. | |
| 52 | wayland-backend = { version = "0.3.17", features = ["client_system"] } | |
| 53 | wayland-client = "0.31.15" | |
| 54 | wayland-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 | ||
| 5 | use ring::signature::{Ed25519KeyPair, KeyPair}; | |
| 6 | use std::io::Write; | |
| 7 | ||
| 8 | const PUBLIC: &str = include_str!("../release-key.pub"); | |
| 9 | ||
| 10 | fn hex(bytes: &[u8]) -> String { | |
| 11 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | |
| 12 | } | |
| 13 | ||
| 14 | fn 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 | 20 | `~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR` |
| 21 | 21 | puts the latter elsewhere. |
| 22 | 22 | |
| 23 | Run `./install.sh` to add Snowbound to your app launcher; it copies the app | |
| 24 | into `~/.local`. On Wayland the taskbar, dock and KDE's title bar find | |
| 25 | Snowbound's icon only through this launcher entry, so without it they show a | |
| 26 | generic icon. | |
| 23 | **Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and | |
| 24 | copies it to `~/.local/bin/snowbound`. Options, next to the update settings, | |
| 25 | uninstalls it again; your notebooks stay where they are. | |
| 27 | 26 | |
| 28 | 27 | The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and |
| 29 | 28 | other desktops without server-side decorations an Adwaita-style one drawn by |
| 30 | 29 | Snowbound. |
| 31 | `./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as | |
| 32 | well. | |
| 33 | 30 | |
| 34 | 31 | ## Requirements |
| 35 | 32 |
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. | |
| 4 | set -eu | |
| 5 | here=$(cd "$(dirname "$0")" && pwd) | |
| 6 | data=${XDG_DATA_HOME:-$HOME/.local/share} | |
| 7 | bin=$HOME/.local/bin | |
| 8 | mkdir -p "$bin" "$data/applications" | |
| 9 | cp "$here/bin/snowbound" "$bin/snowbound" | |
| 10 | cp -R "$here/share/icons" "$data/" | |
| 11 | exec_line="$bin/snowbound" | |
| 12 | if [ $# -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\"" | |
| 17 | fi | |
| 18 | sed "s|^Exec=.*|Exec=$exec_line|" "$here/share/applications/snowbound.desktop" \ | |
| 19 | > "$data/applications/snowbound.desktop" | |
| 20 | command -v update-desktop-database >/dev/null && update-desktop-database "$data/applications" || true | |
| 21 | command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$data/icons/hicolor" 2>/dev/null || true | |
| 22 | echo "Installed $bin/snowbound and the Snowbound launcher." |
crates/snowbound/linux/package.sh+2-13| ... | ... | @@ -4,7 +4,6 @@ |
| 4 | 4 | set -eu |
| 5 | 5 | root=$(cd "$(dirname "$0")/../../.." && pwd) |
| 6 | 6 | here="$root/crates/snowbound/linux" |
| 7 | icon="$root/crates/snowbound/assets/icon/Snowbound-SnowLeopard.png" | |
| 8 | 7 | target="$root/target" |
| 9 | 8 | tools="$target/zig" |
| 10 | 9 | mkdir -p "$tools" "$target/dist" |
| ... | ... | @@ -39,19 +38,9 @@ EOF |
| 39 | 38 | name=snowbound-linux-$arch |
| 40 | 39 | stage="$target/dist/$name" |
| 41 | 40 | rm -rf "$stage" |
| 42 | mkdir -p "$stage/bin" "$stage/share/applications" | |
| 41 | mkdir -p "$stage/bin" | |
| 43 | 42 | cp "$target/$triple/release/snowbound" "$stage/bin/" |
| 44 | cp "$here/snowbound.desktop" "$stage/share/applications/" | |
| 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 | |
| 43 | cp "$here/README.md" "$stage/" | |
| 55 | 44 | COPYFILE_DISABLE=1 tar --no-xattrs -C "$target/dist" -czf "$target/dist/$name.tar.gz" "$name" |
| 56 | 45 | echo "$target/dist/$name.tar.gz" |
| 57 | 46 | done |
crates/snowbound/linux/snowbound.desktop+2-2| ... | ... | @@ -4,7 +4,7 @@ Name=Snowbound |
| 4 | 4 | GenericName=Note Taking |
| 5 | 5 | Comment=Type and draw notes in OneNote notebooks |
| 6 | 6 | Exec=snowbound |
| 7 | Icon=snowbound | |
| 7 | Icon=net.paperclover.snowbound | |
| 8 | 8 | Terminal=false |
| 9 | 9 | Categories=Office; |
| 10 | StartupWMClass=snowbound | |
| 10 | StartupWMClass=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 @@ |
| 1 | 5af6766e8e2204ee52f329af4f5233be3b06419c1df0f8c7d82112b31d346b09 |
crates/snowbound/src/aqua.rs+184-6| ... | ... | @@ -12,16 +12,29 @@ use objc2::{ |
| 12 | 12 | sel, |
| 13 | 13 | }; |
| 14 | 14 | use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize}; |
| 15 | use std::collections::HashMap; | |
| 15 | use std::{ | |
| 16 | collections::HashMap, | |
| 17 | sync::atomic::{AtomicBool, Ordering}, | |
| 18 | }; | |
| 16 | 19 | use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; |
| 17 | 20 | use winit::{ |
| 18 | 21 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, |
| 19 | 22 | window::{CursorIcon, Window}, |
| 20 | 23 | }; |
| 21 | 24 | |
| 25 | static PRETEND: AtomicBool = AtomicBool::new(false); | |
| 26 | ||
| 27 | /// Lays the app out as on 10.6, for `--screenshot` to draw a Snow Leopard window anywhere. | |
| 28 | pub fn pretend() { | |
| 29 | PRETEND.store(true, Ordering::Relaxed); | |
| 30 | } | |
| 31 | ||
| 22 | 32 | /// Whether the system predates 10.7, which moved scrollers over the content, gave |
| 23 | 33 | /// NSScroller its styles and let windows resize from any edge. |
| 24 | 34 | pub(crate) fn before_lion() -> bool { |
| 35 | if PRETEND.load(Ordering::Relaxed) { | |
| 36 | return true; | |
| 37 | } | |
| 25 | 38 | let scroller = AnyClass::get("NSScroller").expect("AppKit is linked"); |
| 26 | 39 | let styled: bool = |
| 27 | 40 | unsafe { msg_send![scroller, respondsToSelector: sel!(preferredScrollerStyle)] }; |
| ... | ... | @@ -35,6 +48,7 @@ pub fn textured(window: &Window, row: f32) -> bool { |
| 35 | 48 | if !before_lion() { |
| 36 | 49 | return false; |
| 37 | 50 | } |
| 51 | TEXTURED.store(true, Ordering::Relaxed); | |
| 38 | 52 | let RawWindowHandle::AppKit(handle) = |
| 39 | 53 | window.window_handle().expect("Live AppKit window").as_raw() |
| 40 | 54 | else { |
| ... | ... | @@ -57,6 +71,28 @@ pub fn textured(window: &Window, row: f32) -> bool { |
| 57 | 71 | true |
| 58 | 72 | } |
| 59 | 73 | |
| 74 | static 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. | |
| 79 | pub 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 | 96 | /// `-mouseDownCanMoveWindow` for the content view: a textured window drags from any press |
| 61 | 97 | /// on a view that allows it, asking once. The app drags from its toolbar's empty space |
| 62 | 98 | /// itself, through `-performWindowDragWithEvent:`. |
| ... | ... | @@ -84,11 +120,22 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { |
| 84 | 120 | Bool::YES |
| 85 | 121 | } |
| 86 | 122 | |
| 87 | /// AppKit's scrollers, on systems whose scrollers sit beside the content. | |
| 88 | pub fn scrollers() -> Option<ui::Scrollers> { | |
| 123 | /// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, and its | |
| 124 | /// scrollers. | |
| 125 | pub fn system_interface(ui: &mut ui::Ui) { | |
| 89 | 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. | |
| 138 | fn scrollers() -> ui::Scrollers { | |
| 92 | 139 | MainThreadMarker::new().expect("Views belong to the main thread"); |
| 93 | 140 | let window_class = objc2_app_kit::NSWindow::class(); |
| 94 | 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 | 160 | let thickness: f64 = unsafe { msg_send![AnyClass::get("NSScroller").unwrap(), scrollerWidth] }; |
| 114 | 161 | let mut views: HashMap<(bool, bool), Retained<AnyObject>> = HashMap::new(); |
| 115 | 162 | let mut painted: HashMap<[u32; 6], PaintedScroller> = HashMap::new(); |
| 116 | Some(ui::Scrollers { | |
| 163 | ui::Scrollers { | |
| 117 | 164 | thickness: thickness as f32, |
| 118 | 165 | paint: Box::new(move |request: &Scroller| { |
| 119 | 166 | let key = [ |
| ... | ... | @@ -152,7 +199,138 @@ pub fn scrollers() -> Option<ui::Scrollers> { |
| 152 | 199 | painted.insert(key, result.clone()); |
| 153 | 200 | 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. | |
| 207 | struct 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. | |
| 214 | fn 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 | ||
| 237 | fn 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. | |
| 272 | fn 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. | |
| 287 | fn 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 | } |
| 157 | 335 | |
| 158 | 336 | unsafe fn paint( |
crates/snowbound/src/art.rs+3| ... | ... | @@ -19,6 +19,8 @@ pub const CLOCK: &[&str] = art!("icons/clock"); |
| 19 | 19 | pub const CLOSE: &[&str] = art!("icons/close"); |
| 20 | 20 | pub const CONFLICT: &[&str] = art!("icons/conflict"); |
| 21 | 21 | pub const EQUATION: &[&str] = art!("icons/equation"); |
| 22 | pub const FOLDER: &[&str] = art!("icons/folder"); | |
| 23 | pub const ICLOUD: &[&str] = art!("icons/icloud"); | |
| 22 | 24 | pub const FONT_COLOR: &[&str] = art!("icons/font-color"); |
| 23 | 25 | pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); |
| 24 | 26 | pub const INDENT: &[&str] = art!("icons/indent"); |
| ... | ... | @@ -35,6 +37,7 @@ pub const REDO: &[&str] = art!("icons/redo"); |
| 35 | 37 | pub const SEARCH: &[&str] = art!("icons/search"); |
| 36 | 38 | pub const SECTION: &[&str] = art!("icons/section"); |
| 37 | 39 | pub const SECTION_GROUP: &[&str] = art!("icons/section-group"); |
| 40 | pub const SERVER: &[&str] = art!("icons/server"); | |
| 38 | 41 | pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); |
| 39 | 42 | pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); |
| 40 | 43 | pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); |
crates/snowbound/src/attachment.rs+1-1| ... | ... | @@ -94,7 +94,7 @@ impl State { |
| 94 | 94 | unstored(); |
| 95 | 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 | 98 | std::fs::write(path, bytes)?; |
| 99 | 99 | } |
| 100 | 100 | Ok(()) |
crates/snowbound/src/background.rs+13| ... | ... | @@ -146,6 +146,19 @@ pub fn menu( |
| 146 | 146 | ); |
| 147 | 147 | }; |
| 148 | 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 | 162 | match cells[index] { |
| 150 | 163 | Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"), |
| 151 | 164 | Cell::ShowAll => label(ui, "Show All…"), |
crates/snowbound/src/commands.rs+116-40| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | //! chords on each platform, when it applies and what it does. |
| 3 | 3 | |
| 4 | 4 | use crate::recording::{Media, Transport}; |
| 5 | use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; | |
| 5 | use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, platform, search}; | |
| 6 | 6 | use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle}; |
| 7 | 7 | use canvas::interaction::{Request, ink::Tool}; |
| 8 | 8 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; |
| ... | ... | @@ -14,6 +14,7 @@ pub enum Id { |
| 14 | 14 | Settings, |
| 15 | 15 | NewNotebook, |
| 16 | 16 | OpenNotebook, |
| 17 | OpenFromServer, | |
| 17 | 18 | CloseNotebook, |
| 18 | 19 | NewSection, |
| 19 | 20 | NewSectionGroup, |
| ... | ... | @@ -32,6 +33,9 @@ pub enum Id { |
| 32 | 33 | Find, |
| 33 | 34 | Search, |
| 34 | 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 | 39 | CommandPalette, |
| 36 | 40 | Back, |
| 37 | 41 | Forward, |
| ... | ... | @@ -42,6 +46,10 @@ pub enum Id { |
| 42 | 46 | PageList, |
| 43 | 47 | DarkPages, |
| 44 | 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 | 53 | PageColor, |
| 46 | 54 | Table, |
| 47 | 55 | Picture, |
| ... | ... | @@ -78,6 +86,13 @@ pub enum Id { |
| 78 | 86 | RemoveTags, |
| 79 | 87 | FindTags, |
| 80 | 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. | |
| 94 | pub fn offered(id: Id) -> bool { | |
| 95 | id != Id::ScreenClipping || cfg!(target_os = "macos") | |
| 81 | 96 | } |
| 82 | 97 | |
| 83 | 98 | /// What a menu or toolbar offers: a command, or one of a list's entries. |
| ... | ... | @@ -106,7 +121,8 @@ pub enum Choice { |
| 106 | 121 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 107 | 122 | pub struct Status { |
| 108 | 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 | } |
| 111 | 127 | |
| 112 | 128 | /// A key with modifiers; `command` is the shortcut modifier, Control off macOS. |
| ... | ... | @@ -196,6 +212,7 @@ pub const COMMANDS: &[Command] = &[ |
| 196 | 212 | row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]), |
| 197 | 213 | row!(Id::NewNotebook, "New Notebook…", NONE, NONE), |
| 198 | 214 | row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]), |
| 215 | row!(Id::OpenFromServer, "Open Notebook from Server…", NONE, NONE), | |
| 199 | 216 | row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE), |
| 200 | 217 | row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]), |
| 201 | 218 | row!(Id::NewSectionGroup, "New Section Group", NONE, NONE), |
| ... | ... | @@ -243,6 +260,7 @@ pub const COMMANDS: &[Command] = &[ |
| 243 | 260 | &[cmd('f').option()], |
| 244 | 261 | &[key('o').option()], |
| 245 | 262 | ), |
| 263 | row!(Id::GoTo, "Go to Page or Section…", &[cmd('p')], &[cmd('p')]), | |
| 246 | 264 | row!( |
| 247 | 265 | Id::CommandPalette, |
| 248 | 266 | "Command Palette…", |
| ... | ... | @@ -279,6 +297,13 @@ pub const COMMANDS: &[Command] = &[ |
| 279 | 297 | row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), |
| 280 | 298 | row!(Id::PageList, "Page List", NONE, NONE), |
| 281 | 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 | 307 | // AppKit's Enter Full Screen takes Control-Command-F. |
| 283 | 308 | row!( |
| 284 | 309 | Id::FullPageView, |
| ... | ... | @@ -443,6 +468,7 @@ pub const COMMANDS: &[Command] = &[ |
| 443 | 468 | ), |
| 444 | 469 | row!(Id::FindTags, "Find Tags", NONE, NONE), |
| 445 | 470 | row!(Id::Help, "Snowbound Help", NONE, NONE), |
| 471 | row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE), | |
| 446 | 472 | ]; |
| 447 | 473 | |
| 448 | 474 | /// The most tags a list holds: MS-ONE's action types 0 to 99 number them. |
| ... | ... | @@ -604,6 +630,8 @@ impl Chord { |
| 604 | 630 | Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift), |
| 605 | 631 | Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift), |
| 606 | 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 | 635 | Press::Named(named) => unreachable!("No command takes {named:?}"), |
| 608 | 636 | } |
| 609 | 637 | } |
| ... | ... | @@ -616,6 +644,7 @@ impl Chord { |
| 616 | 644 | Press::Named(NamedKey::Space) => "Space".to_owned(), |
| 617 | 645 | Press::Named(NamedKey::ArrowLeft) => "←".to_owned(), |
| 618 | 646 | Press::Named(NamedKey::ArrowRight) => "→".to_owned(), |
| 647 | Press::Named(NamedKey::F7) => "F7".to_owned(), | |
| 619 | 648 | _ => key.to_uppercase(), |
| 620 | 649 | }; |
| 621 | 650 | [ |
| ... | ... | @@ -690,24 +719,26 @@ impl State { |
| 690 | 719 | pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status { |
| 691 | 720 | let session = self.session.as_ref(); |
| 692 | 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 | 726 | let page = (session.is_some() || self.temporary) && !modal; |
| 695 | 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 | 731 | // Edit commands act on a focused field instead of the page. |
| 698 | let field = self | |
| 699 | .ui | |
| 700 | .focused() | |
| 701 | .is_some_and(|focus| focus != crate::page()); | |
| 732 | let field = self.ui.focused_field().is_some(); | |
| 702 | 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 | 735 | let enabled = |enabled| Status { |
| 705 | 736 | enabled, |
| 706 | checked: false, | |
| 737 | checked: None, | |
| 707 | 738 | }; |
| 708 | 739 | let checked = |checked| Status { |
| 709 | 740 | enabled: text, |
| 710 | checked, | |
| 741 | checked: Some(checked), | |
| 711 | 742 | }; |
| 712 | 743 | let id = match choice { |
| 713 | 744 | Choice::Command(id) => *id, |
| ... | ... | @@ -723,10 +754,19 @@ impl State { |
| 723 | 754 | Id::Settings |
| 724 | 755 | | Id::NewNotebook |
| 725 | 756 | | Id::OpenNotebook |
| 757 | | Id::OpenFromServer | |
| 758 | | Id::GoTo | |
| 726 | 759 | | Id::CommandPalette |
| 727 | 760 | | Id::CustomizeTags |
| 728 | | Id::Help => enabled(!modal), | |
| 729 | Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()), | |
| 761 | | Id::Help | |
| 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 | 770 | Id::NewSection | Id::NewSectionGroup => enabled( |
| 731 | 771 | !modal |
| 732 | 772 | && self |
| ... | ... | @@ -736,17 +776,19 @@ impl State { |
| 736 | 776 | Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => { |
| 737 | 777 | enabled(!modal && session.is_some()) |
| 738 | 778 | } |
| 739 | Id::PageVersions => { | |
| 740 | session | |
| 741 | .filter(|_| !modal) | |
| 742 | .map_or_else(Status::default, |session| Status { | |
| 743 | enabled: !session.page_versions(session.space).is_empty(), | |
| 744 | checked: session.shown_history == Some(session.space), | |
| 745 | }) | |
| 746 | } | |
| 779 | Id::PageVersions => session.filter(|_| !modal).map_or( | |
| 780 | Status { | |
| 781 | enabled: false, | |
| 782 | checked: Some(false), | |
| 783 | }, | |
| 784 | |session| Status { | |
| 785 | enabled: !session.page_versions(session.space).is_empty(), | |
| 786 | checked: Some(session.shown_history == Some(session.space)), | |
| 787 | }, | |
| 788 | ), | |
| 747 | 789 | Id::FullPageView => Status { |
| 748 | 790 | enabled: !welcome && !modal, |
| 749 | checked: self.full_page, | |
| 791 | checked: Some(self.full_page), | |
| 750 | 792 | }, |
| 751 | 793 | Id::Undo => enabled(writable && !field && self.view.editor.can_undo()), |
| 752 | 794 | Id::Redo => enabled(writable && !field && self.view.editor.can_redo()), |
| ... | ... | @@ -759,58 +801,70 @@ impl State { |
| 759 | 801 | Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page), |
| 760 | 802 | Id::Sidebar => Status { |
| 761 | 803 | enabled: !welcome && !modal, |
| 762 | checked: self.sidebar, | |
| 804 | checked: Some(self.sidebar), | |
| 763 | 805 | }, |
| 764 | 806 | Id::PageList => Status { |
| 765 | 807 | enabled: !modal && session.is_some(), |
| 766 | checked: self.pages_open, | |
| 808 | checked: Some(self.pages_open), | |
| 767 | 809 | }, |
| 768 | 810 | Id::SearchResults => Status { |
| 769 | 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 | 817 | Id::FindTags => Status { |
| 773 | 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 | 824 | Id::DarkPages => Status { |
| 777 | 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 | 833 | Id::FormatPainter => Status { |
| 781 | 834 | enabled: text, |
| 782 | checked: self.painter.is_some(), | |
| 835 | checked: Some(self.painter.is_some()), | |
| 783 | 836 | }, |
| 784 | 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 | 839 | Id::RecordAudio | Id::RecordVideo => { |
| 787 | 840 | let recording = match self.media { |
| 788 | 841 | Media::Recording { video, .. } => Some(video), |
| 789 | 842 | _ => None, |
| 790 | 843 | }; |
| 791 | 844 | Status { |
| 792 | enabled: recording.is_some() || text && !modal, | |
| 793 | checked: recording == Some(id == Id::RecordVideo), | |
| 845 | enabled: recording.is_some() | |
| 846 | || text && !modal && !matches!(self.media, Media::Saving { .. }), | |
| 847 | checked: Some(recording == Some(id == Id::RecordVideo)), | |
| 794 | 848 | } |
| 795 | 849 | } |
| 796 | 850 | Id::Transport(transport) => self.transport_status(transport), |
| 797 | 851 | Id::InsertSpace => Status { |
| 798 | 852 | enabled: writable, |
| 799 | checked: self.view.inserting_space(), | |
| 853 | checked: Some(self.view.inserting_space()), | |
| 800 | 854 | }, |
| 801 | 855 | Id::SelectType => Status { |
| 802 | 856 | enabled: page, |
| 803 | checked: tool == Tool::Select, | |
| 857 | checked: Some(tool == Tool::Select), | |
| 804 | 858 | }, |
| 805 | 859 | Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => Status { |
| 806 | 860 | enabled: writable, |
| 807 | checked: match (id, tool) { | |
| 861 | checked: Some(match (id, tool) { | |
| 808 | 862 | (Id::Pen, Tool::Pen(_)) |
| 809 | 863 | | (Id::Eraser, Tool::Eraser) |
| 810 | 864 | | (Id::Lasso, Tool::Lasso) => true, |
| 811 | 865 | (Id::Shape(kind), Tool::Shape(shape, _)) => kind == shape, |
| 812 | 866 | _ => false, |
| 813 | }, | |
| 867 | }), | |
| 814 | 868 | }, |
| 815 | 869 | Id::Table |
| 816 | 870 | | Id::Picture |
| ... | ... | @@ -832,7 +886,7 @@ impl State { |
| 832 | 886 | Id::Numbering => checked(format.numbering), |
| 833 | 887 | Id::Align(alignment) => checked(format.alignment == Some(alignment)), |
| 834 | 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 | 890 | None => Status::default(), |
| 837 | 891 | }, |
| 838 | 892 | } |
| ... | ... | @@ -930,7 +984,7 @@ impl State { |
| 930 | 984 | Id::Pen |
| 931 | 985 | } |
| 932 | 986 | }; |
| 933 | let field = self.ui.focused().filter(|focus| *focus != page()); | |
| 987 | let field = self.ui.focused_field(); | |
| 934 | 988 | let response = match id { |
| 935 | 989 | Id::Settings => { |
| 936 | 990 | self.open_options(); |
| ... | ... | @@ -938,6 +992,7 @@ impl State { |
| 938 | 992 | } |
| 939 | 993 | Id::NewNotebook => Work::NewNotebook, |
| 940 | 994 | Id::OpenNotebook => Work::OpenNotebook, |
| 995 | Id::OpenFromServer => Work::OpenFromServer(None), | |
| 941 | 996 | Id::CloseNotebook => { |
| 942 | 997 | Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?)) |
| 943 | 998 | } |
| ... | ... | @@ -1011,12 +1066,21 @@ impl State { |
| 1011 | 1066 | self.start_search(); |
| 1012 | 1067 | return Ok(()); |
| 1013 | 1068 | } |
| 1069 | Id::Spelling => { | |
| 1070 | self.open_spelling_pane(); | |
| 1071 | return Ok(()); | |
| 1072 | } | |
| 1014 | 1073 | Id::SearchResults | Id::FindTags => { |
| 1015 | 1074 | self.toggle_pane(id == Id::FindTags); |
| 1016 | 1075 | return Ok(()); |
| 1017 | 1076 | } |
| 1018 | Id::CommandPalette => { | |
| 1019 | self.ui.open_popup(crate::palette::id()); | |
| 1077 | Id::GoTo | Id::CommandPalette => { | |
| 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 | 1084 | return Ok(()); |
| 1021 | 1085 | } |
| 1022 | 1086 | Id::Back | Id::Forward => { |
| ... | ... | @@ -1077,6 +1141,12 @@ impl State { |
| 1077 | 1141 | platform::character_palette(); |
| 1078 | 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 | 1150 | Id::DarkPages => { |
| 1081 | 1151 | self.light_pages = !self.light_pages; |
| 1082 | 1152 | self.follow_color_scheme(); |
| ... | ... | @@ -1130,6 +1200,10 @@ impl State { |
| 1130 | 1200 | platform::reveal(HELP); |
| 1131 | 1201 | return Ok(()); |
| 1132 | 1202 | } |
| 1203 | Id::CheckForUpdates => { | |
| 1204 | self.updates.check_now(); | |
| 1205 | return Ok(()); | |
| 1206 | } | |
| 1133 | 1207 | Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)), |
| 1134 | 1208 | // The highlighter and font colour apply their last pick. |
| 1135 | 1209 | Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)), |
| ... | ... | @@ -1142,7 +1216,9 @@ impl State { |
| 1142 | 1216 | Id::ClearFormatting => return format(self, Formatting::Clear), |
| 1143 | 1217 | Id::Tag(place) => { |
| 1144 | 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 | 1223 | Id::CustomizeTags => { |
| 1148 | 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 | ||
| 6 | use 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 | }; | |
| 16 | use 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 | }; | |
| 22 | use 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 | }; | |
| 29 | use 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. | |
| 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; | |
| 38 | const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); | |
| 39 | /// 512 pixels square; the smaller sizes are scaled from it. | |
| 40 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); | |
| 41 | const 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. | |
| 43 | const PORTABLE: &str = "X-Snowbound-Portable"; | |
| 44 | ||
| 45 | #[derive(Clone, Copy, PartialEq)] | |
| 46 | enum 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 | ||
| 54 | static INSTALLED: Mutex<Installed> = Mutex::new(Installed::No); | |
| 55 | ||
| 56 | fn installed() -> Installed { | |
| 57 | *INSTALLED.lock().unwrap() | |
| 58 | } | |
| 59 | ||
| 60 | /// Whether the welcome offers Install. | |
| 61 | pub fn installable() -> bool { | |
| 62 | installed() == Installed::No | |
| 63 | } | |
| 64 | ||
| 65 | /// Whether Options offers Uninstall. | |
| 66 | pub fn uninstallable() -> bool { | |
| 67 | installed() == Installed::Here | |
| 68 | } | |
| 69 | ||
| 70 | fn data_home() -> Option<PathBuf> { | |
| 71 | crate::platform::xdg_base("XDG_DATA_HOME", ".local/share") | |
| 72 | } | |
| 73 | ||
| 74 | fn entry(data: &Path) -> PathBuf { | |
| 75 | data.join(format!("applications/{APP_ID}.desktop")) | |
| 76 | } | |
| 77 | ||
| 78 | fn 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. | |
| 83 | pub 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. | |
| 88 | fn 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. | |
| 94 | fn 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. | |
| 102 | pub 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 | ||
| 136 | fn 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. | |
| 150 | fn 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. | |
| 162 | fn 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. | |
| 175 | fn 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. | |
| 190 | fn 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 | ||
| 197 | fn 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 | ||
| 211 | fn 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. | |
| 216 | pub fn window_icon() -> Option<Icon> { | |
| 217 | let icon = scaled(&pixels(), 128); | |
| 218 | Icon::from_rgba(icon.into_raw(), 128, 128).ok() | |
| 219 | } | |
| 220 | ||
| 221 | struct Wayland; | |
| 222 | ||
| 223 | impl 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 | ||
| 235 | wayland_client::delegate_noop!(Wayland: ignore wl_shm::WlShm); | |
| 236 | wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool); | |
| 237 | wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer); | |
| 238 | wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1); | |
| 239 | wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1); | |
| 240 | ||
| 241 | fn 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. | |
| 249 | fn 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 | ||
| 257 | fn 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. | |
| 269 | pub 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. | |
| 336 | pub 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 | ||
| 349 | fn 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. | |
| 381 | pub 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. | |
| 415 | fn 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)] | |
| 425 | mod 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 | ||
| 3 | use notebook::session::{Background, Notebook, Section}; | |
| 4 | use std::path::{Path, PathBuf}; | |
| 5 | ||
| 6 | pub fn ubiquitous(_: &Path) -> bool { | |
| 7 | false | |
| 8 | } | |
| 9 | ||
| 10 | pub fn folder() -> Option<PathBuf> { | |
| 11 | None | |
| 12 | } | |
| 13 | ||
| 14 | pub fn look_up(_: impl Fn() + Send + 'static) {} | |
| 15 | ||
| 16 | pub fn drive() -> Option<PathBuf> { | |
| 17 | None | |
| 18 | } | |
| 19 | ||
| 20 | pub 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 | ||
| 28 | pub 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 | ||
| 36 | pub fn background( | |
| 37 | notebook: &Notebook, | |
| 38 | notify: impl Fn() + Send + 'static, | |
| 39 | ) -> Result<Background, notebook::Error> { | |
| 40 | notebook.background(false, true, notify) | |
| 41 | } | |
| 42 | ||
| 43 | pub struct Presenter; | |
| 44 | ||
| 45 | pub fn presenter(_: &Path, _: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Presenter> { | |
| 46 | None | |
| 47 | } | |
| 48 | ||
| 49 | pub 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 | ||
| 6 | use block2::{RcBlock, StackBlock}; | |
| 7 | use notebook::{ | |
| 8 | Remote, Version, | |
| 9 | session::{Background, Notebook, Section}, | |
| 10 | }; | |
| 11 | use objc2::{ | |
| 12 | ClassType, DeclaredClass, declare_class, msg_send_id, mutability, | |
| 13 | rc::Retained, | |
| 14 | runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject}, | |
| 15 | }; | |
| 16 | use objc2_foundation::{ | |
| 17 | NSFileCoordinator, NSFileCoordinatorReadingOptions, NSFileCoordinatorWritingOptions, | |
| 18 | NSFileManager, NSFilePresenter, NSFileVersion, NSNotification, NSNotificationCenter, | |
| 19 | NSOperationQueue, NSString, NSURL, NSURLIsUbiquitousItemKey, | |
| 20 | NSURLUbiquitousItemDownloadingStatusKey, NSURLUbiquitousItemDownloadingStatusNotDownloaded, | |
| 21 | }; | |
| 22 | use onestore::{CommitError, CommitState, Stamp, Transaction}; | |
| 23 | use 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. | |
| 34 | const 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. | |
| 39 | pub 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. | |
| 42 | fn 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 | ||
| 52 | fn url(path: &Path) -> Option<Retained<NSURL>> { | |
| 53 | Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) }) | |
| 54 | } | |
| 55 | ||
| 56 | fn 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. | |
| 64 | pub 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. | |
| 81 | static 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. | |
| 86 | pub 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`. | |
| 92 | pub 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 | ||
| 102 | fn 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. | |
| 115 | pub 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. | |
| 126 | fn 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. | |
| 164 | fn 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. | |
| 188 | pub struct Coordinated(pub PathBuf); | |
| 189 | ||
| 190 | fn uncommitted(error: io::Error) -> CommitError { | |
| 191 | CommitError { | |
| 192 | state: CommitState::NotCommitted, | |
| 193 | error, | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | impl 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 | ||
| 215 | fn version_path(version: &NSFileVersion) -> Option<PathBuf> { | |
| 216 | Some(PathBuf::from(unsafe { version.URL().path() }?.to_string())) | |
| 217 | } | |
| 218 | ||
| 219 | impl 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. | |
| 307 | pub 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. | |
| 318 | pub 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. | |
| 332 | pub 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 | ||
| 339 | struct Ivars { | |
| 340 | url: Retained<NSURL>, | |
| 341 | root: PathBuf, | |
| 342 | queue: Retained<NSOperationQueue>, | |
| 343 | changed: Box<dyn Fn(Vec<String>) + Send + Sync>, | |
| 344 | } | |
| 345 | ||
| 346 | declare_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 | ||
| 399 | impl 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. | |
| 415 | pub struct Presenter(Retained<FolderPresenter>); | |
| 416 | ||
| 417 | // The presenter is only registered and removed, each safe from any thread. | |
| 418 | unsafe impl Send for Presenter {} | |
| 419 | unsafe 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. | |
| 424 | pub 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 | ||
| 445 | impl 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. | |
| 456 | pub 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)] | |
| 477 | mod 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 | 1 | //! Open notebooks: where each lives and the sections its tabs offer. |
| 2 | 2 | |
| 3 | use canvas::{editor::NoteTag, gpu::TagArt}; | |
| 3 | 4 | use notebook::discover::{Folder, SectionState}; |
| 4 | 5 | use notebook::session::{Background, Notebook, Section}; |
| 5 | 6 | use notebook::smb::{Client, Credentials}; |
| ... | ... | @@ -99,6 +100,116 @@ impl Mount { |
| 99 | 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. | |
| 180 | fn 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. | |
| 204 | fn 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 | } |
| 103 | 214 | |
| 104 | 215 | /// An account on an SMB server. |
| ... | ... | @@ -111,7 +222,6 @@ pub struct Login { |
| 111 | 222 | |
| 112 | 223 | impl Login { |
| 113 | 224 | /// The guest account a mount without one signed in as. |
| 114 | #[cfg(any(target_os = "macos", test))] | |
| 115 | 225 | pub fn guest(mount: &Mount) -> Self { |
| 116 | 226 | Self { |
| 117 | 227 | user: String::new(), |
| ... | ... | @@ -130,7 +240,7 @@ struct Server { |
| 130 | 240 | impl Server { |
| 131 | 241 | fn connect(&self) -> io::Result<Client> { |
| 132 | 242 | Client::connect( |
| 133 | &self.mount.server, | |
| 243 | &self.mount.endpoint(), | |
| 134 | 244 | &self.mount.share, |
| 135 | 245 | Credentials { |
| 136 | 246 | username: &self.login.user, |
| ... | ... | @@ -169,6 +279,8 @@ pub struct Library { |
| 169 | 279 | pub background: Option<Arc<Background>>, |
| 170 | 280 | /// Reports the changes to a mounted notebook's folder to `background`. |
| 171 | 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 | } |
| 173 | 285 | |
| 174 | 286 | impl Library { |
| ... | ... | @@ -177,6 +289,25 @@ impl Library { |
| 177 | 289 | /// account the system keeps for the mount, as OneNote's own client coordinates with |
| 178 | 290 | /// OneNote; without that account it opens through the mount. |
| 179 | 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 | 311 | let mut notice = None; |
| 181 | 312 | if let Some(mount) = crate::platform::smb_mount(Path::new(location)) { |
| 182 | 313 | match crate::platform::smb_login(&mount) |
| ... | ... | @@ -199,6 +330,9 @@ impl Library { |
| 199 | 330 | name: file_name(Path::new(location)), |
| 200 | 331 | background, |
| 201 | 332 | watch, |
| 333 | tag_art: Mutex::new(Arc::new( | |
| 334 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), | |
| 335 | )), | |
| 202 | 336 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 203 | 337 | server: None, |
| 204 | 338 | notice, |
| ... | ... | @@ -215,7 +349,9 @@ impl Library { |
| 215 | 349 | cache: &Path, |
| 216 | 350 | ) -> Result<Self, String> { |
| 217 | 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 | 355 | let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) |
| 220 | 356 | .map_err(|error| error.to_string())?; |
| 221 | 357 | let connect = Arc::clone(&server); |
| ... | ... | @@ -230,7 +366,11 @@ impl Library { |
| 230 | 366 | background.watch(notebook.replicas()); |
| 231 | 367 | Ok(Self { |
| 232 | 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 | 374 | notebook: Ok(Some(notebook)), |
| 235 | 375 | server: Some(server), |
| 236 | 376 | notice: None, |
| ... | ... | @@ -240,6 +380,50 @@ impl Library { |
| 240 | 380 | }) |
| 241 | 381 | } |
| 242 | 382 | |
| 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 | 427 | /// This notebook read again, the way it was opened, for changing its structure. |
| 244 | 428 | pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> { |
| 245 | 429 | Ok(match &self.server { |
| ... | ... | @@ -274,6 +458,7 @@ impl Library { |
| 274 | 458 | cache: self.cache.clone(), |
| 275 | 459 | background: self.background.clone(), |
| 276 | 460 | watch, |
| 461 | tag_art: Mutex::new(self.tag_art()), | |
| 277 | 462 | } |
| 278 | 463 | } |
| 279 | 464 | |
| ... | ... | @@ -285,6 +470,7 @@ impl Library { |
| 285 | 470 | name: file_name(Path::new(location)), |
| 286 | 471 | background, |
| 287 | 472 | watch, |
| 473 | tag_art: Default::default(), | |
| 288 | 474 | notebook: Ok(Some(notebook)), |
| 289 | 475 | server: None, |
| 290 | 476 | notice: None, |
| ... | ... | @@ -303,6 +489,7 @@ impl Library { |
| 303 | 489 | cache: cache.to_owned(), |
| 304 | 490 | background: None, |
| 305 | 491 | watch: None, |
| 492 | tag_art: Default::default(), | |
| 306 | 493 | } |
| 307 | 494 | } |
| 308 | 495 | |
| ... | ... | @@ -333,7 +520,7 @@ impl Library { |
| 333 | 520 | root => format!("{root}/{path}"), |
| 334 | 521 | }; |
| 335 | 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 | 524 | let replica = if cache.exists() { |
| 338 | 525 | notebook::Replica::open(&cache) |
| 339 | 526 | } else { |
| ... | ... | @@ -348,7 +535,13 @@ impl Library { |
| 348 | 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 | 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 | 545 | (Ok(None), _) => Section::open(path, &self.cache, notifier()), |
| 353 | 546 | (Err(error), _) => return Err(error.clone().into()), |
| 354 | 547 | }; |
| ... | ... | @@ -379,7 +572,20 @@ impl Library { |
| 379 | 572 | } |
| 380 | 573 | let server = self.server.as_ref()?; |
| 381 | 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 | } |
| 384 | 590 | |
| 385 | 591 | /// How the notebook's files are reached. |
| ... | ... | @@ -390,6 +596,7 @@ impl Library { |
| 390 | 596 | server.mount.server, server.mount.share |
| 391 | 597 | ), |
| 392 | 598 | (None, Some(_)) => "The system’s mount of the share".to_owned(), |
| 599 | (None, None) if self.in_icloud() => "iCloud Drive".to_owned(), | |
| 393 | 600 | (None, None) => "Folder on this computer".to_owned(), |
| 394 | 601 | } |
| 395 | 602 | } |
| ... | ... | @@ -467,6 +674,15 @@ impl Library { |
| 467 | 674 | } |
| 468 | 675 | } |
| 469 | 676 | |
| 677 | /// The share folder a notebook opened from its server's address lives in, as its location | |
| 678 | /// keeps it. | |
| 679 | pub fn server_address(location: &str) -> Option<Mount> { | |
| 680 | location | |
| 681 | .starts_with("smb://") | |
| 682 | .then(|| Mount::from_address(location)) | |
| 683 | .flatten() | |
| 684 | } | |
| 685 | ||
| 470 | 686 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. |
| 471 | 687 | pub fn recycle_bin(path: &str) -> bool { |
| 472 | 688 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| ... | ... | @@ -475,6 +691,27 @@ pub fn recycle_bin(path: &str) -> bool { |
| 475 | 691 | /// A mounted notebook's background sync, following Work Offline, and the watch that reports |
| 476 | 692 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a |
| 477 | 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. | |
| 695 | fn 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 | ||
| 478 | 715 | fn local_background( |
| 479 | 716 | notebook: &mut Notebook, |
| 480 | 717 | location: &str, |
| ... | ... | @@ -482,8 +719,11 @@ fn local_background( |
| 482 | 719 | let reports = Arc::new(OnceLock::new()); |
| 483 | 720 | let watch = watcher(location, Arc::clone(&reports)); |
| 484 | 721 | let copies = !crate::watch::on_this_computer(Path::new(location)); |
| 485 | let Some(background) = notebook | |
| 486 | .background(watch.is_some(), copies, notify_background) | |
| 722 | let background = match crate::icloud::ubiquitous(Path::new(location)) { | |
| 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 | 727 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) |
| 488 | 728 | .ok() |
| 489 | 729 | else { |
| ... | ... | @@ -619,27 +859,54 @@ pub fn locate(path: &Path) -> Located { |
| 619 | 859 | mod tests { |
| 620 | 860 | use super::*; |
| 621 | 861 | |
| 622 | /// A notebook on a share opens, edits and publishes through the embedded client, as a | |
| 623 | /// mounted share's notebook does: `ONESTORE_SMB_LAB=127.0.0.1:PORT` (a disposable Samba | |
| 624 | /// share `agent`, guest access; `tools/w7/linux_vm.py up NAME`). | |
| 862 | /// Removes the folder at `path` on the share and everything in it. | |
| 863 | fn remove_tree(client: &Client, path: &str) { | |
| 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 | 880 | #[test] |
| 626 | 881 | #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] |
| 627 | 882 | fn a_notebook_on_a_share_opens_through_the_embedded_client() { |
| 628 | 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 | 892 | let mount = Mount { |
| 631 | 893 | server: address, |
| 632 | 894 | share: "agent".into(), |
| 633 | user: None, | |
| 895 | user: user.clone(), | |
| 634 | 896 | domain: String::new(), |
| 635 | 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 | 904 | let server = Server { |
| 639 | 905 | mount: mount.clone(), |
| 640 | 906 | login: login.clone(), |
| 641 | 907 | }; |
| 642 | 908 | let client = Arc::new(server.connect().unwrap()); |
| 909 | remove_tree(&client, &root); | |
| 643 | 910 | client.create_directory(&root).unwrap(); |
| 644 | 911 | let cache = std::env::temp_dir().join(format!("snowbound-share-{}", std::process::id())); |
| 645 | 912 | let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap(); |
| ... | ... | @@ -647,7 +914,9 @@ mod tests { |
| 647 | 914 | .unwrap() |
| 648 | 915 | .create_section("", "New Section 1", &page) |
| 649 | 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 | 920 | let path = library.first_section().unwrap(); |
| 652 | 921 | let section = library.open(&path, || {}).unwrap(); |
| 653 | 922 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| ... | ... | @@ -681,7 +950,14 @@ mod tests { |
| 681 | 950 | let deadline = std::time::Instant::now() + Duration::from_secs(60); |
| 682 | 951 | let file = format!("{root}/{path}"); |
| 683 | 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 | 961 | let arena = onestore::Arena::default(); |
| 686 | 962 | let titles = onestore::Section::open(&arena, bytes) |
| 687 | 963 | .unwrap() |
| ... | ... | @@ -695,7 +971,12 @@ mod tests { |
| 695 | 971 | std::thread::sleep(Duration::from_millis(200)); |
| 696 | 972 | } |
| 697 | 973 | section.close().unwrap(); |
| 974 | drop(library); | |
| 698 | 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 | } |
| 700 | 981 | |
| 701 | 982 | #[test] |
| ... | ... | @@ -722,6 +1003,7 @@ mod tests { |
| 722 | 1003 | cache: PathBuf::new(), |
| 723 | 1004 | background: None, |
| 724 | 1005 | watch: None, |
| 1006 | tag_art: Default::default(), | |
| 725 | 1007 | }; |
| 726 | 1008 | let shown = |root, file| library(root).local(Path::new(file)); |
| 727 | 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 | 90 | impl State { |
| 91 | 91 | /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links. |
| 92 | 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 | 97 | self.link = Some(LinkDialog { |
| 95 | 98 | text, |
| 96 | 99 | address, |
| ... | ... | @@ -113,12 +116,6 @@ impl State { |
| 113 | 116 | } |
| 114 | 117 | let theme = ui.theme.clone(); |
| 115 | 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 | 119 | let fields = [text_field(), address_field(), search_field()]; |
| 123 | 120 | let entered = |
| 124 | 121 | ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter); |
| ... | ... | @@ -133,10 +130,14 @@ impl State { |
| 133 | 130 | radius: 8.0, |
| 134 | 131 | pad: [16.0, 12.0], |
| 135 | 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 | 135 | ..Spec::default() |
| 138 | 136 | }, |
| 139 | 137 | ); |
| 138 | if let Some(node) = ui.access(id()) { | |
| 139 | node.set_label("Link"); | |
| 140 | } | |
| 140 | 141 | let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| { |
| 141 | 142 | if !label.is_empty() { |
| 142 | 143 | ui.leaf( |
| ... | ... | @@ -162,6 +163,9 @@ impl State { |
| 162 | 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 | 170 | field(ui, "Text to display:", text_field(), &mut dialog.text, ""); |
| 167 | 171 | field(ui, "Address:", address_field(), &mut dialog.address, ""); |
| ... | ... | @@ -196,6 +200,7 @@ impl State { |
| 196 | 200 | radius: 4.0, |
| 197 | 201 | pad: [4.0, 4.0], |
| 198 | 202 | flags: Flags::CLIP, |
| 203 | role: Some(accesskit::Role::List), | |
| 199 | 204 | ..Spec::default() |
| 200 | 205 | }, |
| 201 | 206 | ); |
| ... | ... | @@ -213,6 +218,7 @@ impl State { |
| 213 | 218 | hover_fill: Some(theme.hover()), |
| 214 | 219 | radius: 4.0, |
| 215 | 220 | pad: [8.0, 0.0], |
| 221 | role: Some(accesskit::Role::ListItem), | |
| 216 | 222 | ..Spec::default() |
| 217 | 223 | }; |
| 218 | 224 | if ui.leaf(space, spec).clicked { |
| ... | ... | @@ -301,13 +307,52 @@ impl State { |
| 301 | 307 | text, |
| 302 | 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 | 349 | // OneNote 2010's commands for the file itself; its clipboard holds text alone. |
| 306 | 350 | vec![item("Open"), item("Save As…")] |
| 307 | 351 | } else { |
| 308 | 352 | let mut items = vec![ |
| 309 | 353 | Item { |
| 310 | 354 | disabled: !context.selected, |
| 355 | separated: corrections > 0, | |
| 311 | 356 | ..item("Cut") |
| 312 | 357 | }, |
| 313 | 358 | Item { |
| ... | ... | @@ -348,16 +393,43 @@ impl State { |
| 348 | 393 | ]); |
| 349 | 394 | } |
| 350 | 395 | items |
| 351 | }; | |
| 396 | }); | |
| 352 | 397 | let Some(chosen) = |
| 353 | 398 | ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None) |
| 354 | 399 | else { |
| 355 | 400 | return Ok(()); |
| 356 | 401 | }; |
| 402 | let chosen_text = items[chosen].text.to_owned(); | |
| 357 | 403 | let Some((context, _)) = self.text_menu.take() else { |
| 358 | 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 | 433 | "Open" => return self.open_attachment(&context.attachment.unwrap()), |
| 362 | 434 | "Save As…" => return self.save_attachment(&context.attachment.unwrap()), |
| 363 | 435 | "Cut" => self.view.copy(true)?, |
crates/snowbound/src/linux.rs+91-67| ... | ... | @@ -18,11 +18,9 @@ use winit::{ |
| 18 | 18 | error::EventLoopError, |
| 19 | 19 | event_loop::{EventLoop, EventLoopProxy}, |
| 20 | 20 | platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland}, |
| 21 | window::{Icon, ResizeDirection, Theme, Window, WindowAttributes}, | |
| 21 | window::{ResizeDirection, Theme, Window, WindowAttributes}, | |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | /// The name the desktop entry, the icon theme and window matching know the app by. | |
| 25 | const APP_ID: &str = "snowbound"; | |
| 26 | 24 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 27 | 25 | pub const LEADING: f32 = 8.0; |
| 28 | 26 | /// The margin past the window controls. |
| ... | ... | @@ -40,10 +38,13 @@ static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| 40 | 38 | /// KWin leaves windows square. |
| 41 | 39 | static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); |
| 42 | 40 | |
| 43 | pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { | |
| 41 | pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { | |
| 44 | 42 | // Month and day names and date orders follow the user's locale; other categories stay C. |
| 45 | 43 | unsafe { libc::setlocale(libc::LC_TIME, c"".as_ptr()) }; |
| 46 | 44 | let event_loop = EventLoop::with_user_event().build()?; |
| 45 | if !headless { | |
| 46 | crate::desktop::prepare(&event_loop); | |
| 47 | } | |
| 47 | 48 | QUIT.set(event_loop.create_proxy()) |
| 48 | 49 | .expect("Only one application event loop is created"); |
| 49 | 50 | watch_settings(event_loop.create_proxy()); |
| ... | ... | @@ -148,15 +149,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> { |
| 148 | 149 | /// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies |
| 149 | 150 | /// the icon on Wayland, while X11 takes it from the window. |
| 150 | 151 | pub 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 | 153 | Window::default_attributes() |
| 153 | 154 | .with_name(APP_ID, APP_ID) |
| 154 | .with_window_icon(icon) | |
| 155 | .with_window_icon(crate::desktop::window_icon()) | |
| 155 | 156 | .with_transparent(cuts_corners()) |
| 156 | 157 | } |
| 157 | 158 | |
| 158 | /// The window manager or the frame draws the title bar. | |
| 159 | pub fn install_title_bar(_: &Window) {} | |
| 159 | /// The window manager or the frame draws the title bar; the icon is the window's own where | |
| 160 | /// the compositor allows. | |
| 161 | pub fn install_title_bar(window: &Window) { | |
| 162 | crate::desktop::set_toplevel_icon(window); | |
| 163 | } | |
| 160 | 164 | |
| 161 | 165 | /// Wayland's clipboard through the window's own connection, as not every compositor offers |
| 162 | 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 | 526 | hover_fill: Some(disc(0.18)), |
| 523 | 527 | radius: ui::shell::TOOL / 2.0, |
| 524 | 528 | center: true, |
| 529 | role: Some(accesskit::Role::Button), | |
| 525 | 530 | ..Spec::default() |
| 526 | 531 | }, |
| 527 | 532 | ) |
| 528 | 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 | 543 | match *control { |
| 530 | 544 | _ if !clicked => {} |
| 531 | 545 | "minimize" => window.set_minimized(true), |
| ... | ... | @@ -581,11 +595,16 @@ pub fn move_cursor() -> winit::window::CursorIcon { |
| 581 | 595 | winit::window::CursorIcon::Move |
| 582 | 596 | } |
| 583 | 597 | |
| 584 | /// Scrollbars overlay the content. | |
| 585 | pub fn scrollers() -> Option<ui::Scrollers> { | |
| 586 | None | |
| 598 | /// No system border lies under the chrome. | |
| 599 | pub fn cover_border_line(_: &mut Ui, _: f32) {} | |
| 600 | ||
| 601 | pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { | |
| 602 | run() | |
| 587 | 603 | } |
| 588 | 604 | |
| 605 | /// The interface keeps fontconfig's font and scrollbars overlay the content. | |
| 606 | pub fn system_interface(_: &mut Ui) {} | |
| 607 | ||
| 589 | 608 | /// Resizing takes the window's edges. |
| 590 | 609 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} |
| 591 | 610 | |
| ... | ... | @@ -605,6 +624,11 @@ pub fn configure_presentation(_: &wgpu::Surface<'_>) {} |
| 605 | 624 | #[cfg(feature = "wgpu")] |
| 606 | 625 | pub fn commit_presentation(_: &Window) {} |
| 607 | 626 | |
| 627 | /// winit reports no tablet pressure here, so every stroke keeps its pen's width. | |
| 628 | pub fn pen_pressure() -> Option<f32> { | |
| 629 | None | |
| 630 | } | |
| 631 | ||
| 608 | 632 | /// GTK's default double-click time. |
| 609 | 633 | pub fn double_click_interval() -> Duration { |
| 610 | 634 | Duration::from_millis(400) |
| ... | ... | @@ -766,7 +790,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 766 | 790 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { |
| 767 | 791 | let user = mount.user.clone().unwrap_or_default(); |
| 768 | 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 | 794 | if !user.is_empty() { |
| 771 | 795 | lookup.args(["user", &user]); |
| 772 | 796 | } |
| ... | ... | @@ -802,72 +826,57 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, |
| 802 | 826 | }) |
| 803 | 827 | } |
| 804 | 828 | |
| 829 | /// What the sign-in offers for keeping a password, where the Secret Service's tool is | |
| 830 | /// installed to keep it. | |
| 831 | pub 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. | |
| 840 | pub 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 | ||
| 805 | 864 | pub fn settings_dir() -> Option<PathBuf> { |
| 806 | 865 | xdg_dir("XDG_CONFIG_HOME", ".config") |
| 807 | 866 | } |
| 808 | 867 | |
| 809 | 868 | /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. |
| 810 | 869 | fn 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 | } |
| 813 | 872 | |
| 814 | fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> { | |
| 873 | pub(crate) fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> { | |
| 815 | 874 | std::env::var_os(variable) |
| 816 | 875 | .map(PathBuf::from) |
| 817 | 876 | .filter(|path| path.is_absolute()) |
| 818 | 877 | .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) |
| 819 | 878 | } |
| 820 | 879 | |
| 821 | /// Looks beside the executable first, as the release archive installs it, then in the | |
| 822 | /// XDG data directories. | |
| 823 | fn 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 | ||
| 852 | fn 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 | 880 | /// The account's full name from its passwd entry, or its login where that has none. |
| 872 | 881 | pub fn user_name() -> String { |
| 873 | 882 | let mut entry: libc::passwd = unsafe { std::mem::zeroed() }; |
| ... | ... | @@ -1170,7 +1179,12 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1170 | 1179 | } |
| 1171 | 1180 | |
| 1172 | 1181 | /// Asks where to put something named `name` by default; the dialog names its own button. |
| 1173 | pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> { | |
| 1182 | pub fn pick_new( | |
| 1183 | title: &str, | |
| 1184 | name: &str, | |
| 1185 | _action: &str, | |
| 1186 | _folder: Option<&std::path::Path>, | |
| 1187 | ) -> Option<PathBuf> { | |
| 1174 | 1188 | let asked = dialog( |
| 1175 | 1189 | [ |
| 1176 | 1190 | "--file-selection", |
| ... | ... | @@ -1191,18 +1205,28 @@ pub fn pick_new(title: &str, name: &str, _action: &str) -> Option<PathBuf> { |
| 1191 | 1205 | |
| 1192 | 1206 | /// Tells the user something they asked for could not be done: `message`, then what to do. |
| 1193 | 1207 | pub 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. | |
| 1212 | pub 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`. | |
| 1217 | fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) { | |
| 1194 | 1218 | let (title, text) = (message.to_owned(), detail.to_owned()); |
| 1195 | 1219 | std::thread::spawn(move || { |
| 1196 | 1220 | let shown = Command::new("zenity") |
| 1197 | 1221 | .args([ |
| 1198 | "--warning", | |
| 1222 | zenity, | |
| 1199 | 1223 | &format!("--title={title}"), |
| 1200 | 1224 | &format!("--text={text}"), |
| 1201 | 1225 | ]) |
| 1202 | 1226 | .status() |
| 1203 | 1227 | .or_else(|_| { |
| 1204 | 1228 | Command::new("kdialog") |
| 1205 | .args(["--sorry", &text, "--title", &title]) | |
| 1229 | .args([kdialog, &text, "--title", &title]) | |
| 1206 | 1230 | .status() |
| 1207 | 1231 | }); |
| 1208 | 1232 | if shown.is_err() { |
crates/snowbound/src/macos.rs+228-15| ... | ... | @@ -10,8 +10,8 @@ use objc2::{ |
| 10 | 10 | }; |
| 11 | 11 | use objc2_app_kit::{ |
| 12 | 12 | NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, |
| 13 | NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType, | |
| 14 | NSMenu, NSMenuItem, | |
| 13 | NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, | |
| 14 | NSEventSubtype, NSEventType, NSMenu, NSMenuItem, | |
| 15 | 15 | }; |
| 16 | 16 | use objc2_foundation::{ |
| 17 | 17 | MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, |
| ... | ... | @@ -29,7 +29,13 @@ use winit::{ |
| 29 | 29 | window::{Window, WindowAttributes}, |
| 30 | 30 | }; |
| 31 | 31 | |
| 32 | pub 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. | |
| 34 | pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { | |
| 35 | objc2::rc::autoreleasepool(|_| run()) | |
| 36 | } | |
| 37 | ||
| 38 | pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; | |
| 33 | 39 | |
| 34 | 40 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| 35 | 41 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| ... | ... | @@ -123,7 +129,7 @@ pub fn install_title_bar(window: &Window) { |
| 123 | 129 | let responds = |
| 124 | 130 | |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; |
| 125 | 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 | 133 | return; |
| 128 | 134 | } |
| 129 | 135 | unsafe { |
| ... | ... | @@ -187,6 +193,9 @@ pub fn window_controls(_: &mut ui::Ui, _: &Window) {} |
| 187 | 193 | /// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and |
| 188 | 194 | /// otherwise the app draws it around the traffic lights. |
| 189 | 195 | pub fn system_titlebar(window: &Window) -> bool { |
| 196 | if crate::aqua::before_lion() { | |
| 197 | return true; | |
| 198 | } | |
| 190 | 199 | let window = ns_window(window); |
| 191 | 200 | unsafe { |
| 192 | 201 | let frame: NSRect = msg_send![&window, frame]; |
| ... | ... | @@ -358,22 +367,150 @@ unsafe extern "C" { |
| 358 | 367 | list: *const std::ffi::c_void, |
| 359 | 368 | data: *mut std::ffi::c_void, |
| 360 | 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")] | |
| 396 | unsafe extern "C" { | |
| 397 | fn CFRelease(object: *const std::ffi::c_void); | |
| 398 | } | |
| 399 | ||
| 400 | /// kSecProtocolTypeSMB, as the Finder keeps an SMB server's passwords. | |
| 401 | const SMB: u32 = u32::from_be_bytes(*b"smb "); | |
| 402 | /// errSecDuplicateItem. | |
| 403 | const DUPLICATE: i32 = -25299; | |
| 404 | ||
| 405 | /// What the sign-in offers for keeping a password, which the keychain keeps. | |
| 406 | pub 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. | |
| 412 | pub 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 | ||
| 419 | fn 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 | } |
| 362 | 492 | |
| 363 | 493 | /// 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. | |
| 365 | 496 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { |
| 497 | smb_login_in(std::ptr::null(), mount) | |
| 498 | } | |
| 499 | ||
| 500 | fn smb_login_in( | |
| 501 | keychain: *const std::ffi::c_void, | |
| 502 | mount: &crate::library::Mount, | |
| 503 | ) -> Result<crate::library::Login, String> { | |
| 366 | 504 | let Some(user) = &mount.user else { |
| 367 | 505 | return Ok(crate::library::Login::guest(mount)); |
| 368 | 506 | }; |
| 369 | // kSecProtocolTypeSMB is 'smb '. | |
| 370 | const SMB: u32 = u32::from_be_bytes(*b"smb "); | |
| 507 | let server = mount.host(); | |
| 371 | 508 | let (mut length, mut data) = (0, std::ptr::null_mut()); |
| 372 | 509 | let status = unsafe { |
| 373 | 510 | SecKeychainFindInternetPassword( |
| 374 | std::ptr::null(), | |
| 375 | mount.server.len() as u32, | |
| 376 | mount.server.as_ptr(), | |
| 511 | keychain, | |
| 512 | server.len() as u32, | |
| 513 | server.as_ptr(), | |
| 377 | 514 | 0, |
| 378 | 515 | std::ptr::null(), |
| 379 | 516 | user.len() as u32, |
| ... | ... | @@ -390,8 +527,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, |
| 390 | 527 | }; |
| 391 | 528 | if status != 0 { |
| 392 | 529 | return Err(format!( |
| 393 | "The keychain has no password for {user} on {} ({status})", | |
| 394 | mount.server | |
| 530 | "Enter the password for \u{201c}{user}\u{201d} on \u{201c}{server}\u{201d}." | |
| 395 | 531 | )); |
| 396 | 532 | } |
| 397 | 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 | 566 | } |
| 431 | 567 | } |
| 432 | 568 | |
| 433 | /// Asks where to `action` something named `name` by default, with the system's save panel. | |
| 434 | pub fn pick_new(title: &str, name: &str, action: &str) -> Option<std::path::PathBuf> { | |
| 569 | /// Asks where to `action` something named `name` by default, with the system's save panel, | |
| 570 | /// starting in `folder` where given. | |
| 571 | pub fn pick_new( | |
| 572 | title: &str, | |
| 573 | name: &str, | |
| 574 | action: &str, | |
| 575 | folder: Option<&std::path::Path>, | |
| 576 | ) -> Option<std::path::PathBuf> { | |
| 435 | 577 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); |
| 436 | 578 | unsafe { |
| 437 | 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 | 585 | panel.setCanCreateDirectories(true); |
| 439 | 586 | panel.setTitle(Some(&NSString::from_str(title))); |
| 440 | 587 | panel.setPrompt(Some(&NSString::from_str(action))); |
| ... | ... | @@ -635,6 +782,18 @@ pub fn install_text_input(window: &Window) { |
| 635 | 782 | } |
| 636 | 783 | } |
| 637 | 784 | |
| 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. | |
| 787 | pub 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 | ||
| 638 | 797 | pub fn double_click_interval() -> std::time::Duration { |
| 639 | 798 | std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() }) |
| 640 | 799 | } |
| ... | ... | @@ -934,7 +1093,7 @@ declare_class!( |
| 934 | 1093 | let status = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| { |
| 935 | 1094 | STATUSES.with_borrow(|statuses| statuses.get(tag).copied()) |
| 936 | 1095 | }).unwrap_or_default(); |
| 937 | unsafe { item.setState(isize::from(status.checked)) }; | |
| 1096 | unsafe { item.setState(isize::from(status.checked == Some(true))) }; | |
| 938 | 1097 | objc2::runtime::Bool::new(status.enabled) |
| 939 | 1098 | } |
| 940 | 1099 | |
| ... | ... | @@ -1082,3 +1241,57 @@ pub fn input_language() -> String { |
| 1082 | 1241 | language.map_or_else(String::new, |language| language.to_string()) |
| 1083 | 1242 | } |
| 1084 | 1243 | } |
| 1244 | ||
| 1245 | #[cfg(test)] | |
| 1246 | mod 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 | 4 | mod attachment; |
| 5 | 5 | mod background; |
| 6 | 6 | mod commands; |
| 7 | #[cfg(test)] | |
| 7 | #[cfg(all(test, feature = "wgpu"))] | |
| 8 | 8 | mod conflict_render; |
| 9 | #[cfg(target_os = "linux")] | |
| 10 | #[path = "desktop_linux.rs"] | |
| 11 | mod desktop; | |
| 9 | 12 | mod history; |
| 13 | #[cfg_attr(target_os = "linux", path = "icloud_linux.rs")] | |
| 14 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] | |
| 15 | mod icloud; | |
| 10 | 16 | mod library; |
| 11 | 17 | mod link; |
| 12 | 18 | mod manage; |
| ... | ... | @@ -28,16 +34,22 @@ mod recording; |
| 28 | 34 | mod rename; |
| 29 | 35 | mod screenshot; |
| 30 | 36 | mod search; |
| 37 | mod server; | |
| 31 | 38 | mod settings; |
| 32 | 39 | mod sidebar; |
| 40 | #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] | |
| 41 | #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] | |
| 42 | mod spell; | |
| 33 | 43 | #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] |
| 34 | 44 | mod surface; |
| 35 | 45 | mod sync; |
| 36 | 46 | mod tags; |
| 37 | 47 | mod templates; |
| 48 | mod update; | |
| 38 | 49 | mod video; |
| 39 | 50 | mod watch; |
| 40 | 51 | |
| 52 | use canvas::gpu::colorref; | |
| 41 | 53 | use canvas::gpu::page::PageScene; |
| 42 | 54 | use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; |
| 43 | 55 | use canvas::{ |
| ... | ... | @@ -46,10 +58,6 @@ use canvas::{ |
| 46 | 58 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, |
| 47 | 59 | layout::TextEngine, |
| 48 | 60 | }; |
| 49 | use canvas::{ | |
| 50 | gpu::{colorref, tag_sources}, | |
| 51 | outline::TagIcon, | |
| 52 | }; | |
| 53 | 61 | use draw::Renderer; |
| 54 | 62 | use library::Library; |
| 55 | 63 | use onestore::ExGuid; |
| ... | ... | @@ -116,11 +124,28 @@ const HIGHLIGHTS: [(u32, &str); 15] = [ |
| 116 | 124 | (0xc0c0c0, "Gray 25%"), |
| 117 | 125 | (0x000000, "Black"), |
| 118 | 126 | ]; |
| 119 | /// Office's theme and standard font colours, COLORREF. | |
| 120 | const FONT_COLORS: [u32; 20] = [ | |
| 121 | 0xffffff, 0x000000, 0xe1ecee, 0x7d491f, 0xbd814f, 0x4d50c0, 0x59bb9b, 0xa26480, 0xc6ac4b, | |
| 122 | 0x4696f7, 0x0000c0, 0x0000ff, 0x00c0ff, 0x00ffff, 0x50d092, 0x50b000, 0xf0b000, 0xc07000, | |
| 123 | 0x602000, 0xa03070, | |
| 127 | /// Office's theme and standard font colours, COLORREF, with the names their tooltips give. | |
| 128 | const FONT_COLORS: [(u32, &str); 20] = [ | |
| 129 | (0xffffff, "White, Background 1"), | |
| 130 | (0x000000, "Black, Text 1"), | |
| 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 | 150 | /// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart |
| 126 | 151 | /// while the gaps still take the pointer. |
| ... | ... | @@ -144,6 +169,8 @@ enum UserEvent { |
| 144 | 169 | Accessibility(accesskit_winit::Event), |
| 145 | 170 | /// The section's synchronization thread reported an event. |
| 146 | 171 | Sync, |
| 172 | /// An update check moved on. | |
| 173 | Update, | |
| 147 | 174 | /// Scripted input from `SNOWBOUND_REPLAY`. |
| 148 | 175 | Replay(Replay), |
| 149 | 176 | /// A page background finished rasterizing on its worker thread. |
| ... | ... | @@ -154,11 +181,36 @@ enum UserEvent { |
| 154 | 181 | /// The desktop's colours changed, where the window system doesn't say so itself. |
| 155 | 182 | #[cfg_attr(target_os = "macos", allow(dead_code))] |
| 156 | 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 | } |
| 158 | 188 | |
| 159 | 189 | /// Asks the event loop for a frame from any thread. |
| 160 | 190 | struct Redraw(EventLoopProxy<UserEvent>); |
| 161 | 191 | |
| 192 | enum Clipboard { | |
| 193 | System(platform::Clipboard), | |
| 194 | Memory(String), | |
| 195 | } | |
| 196 | ||
| 197 | impl 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 | ||
| 162 | 214 | impl std::task::Wake for Redraw { |
| 163 | 215 | fn wake(self: Arc<Self>) { |
| 164 | 216 | let _ = self.0.send_event(UserEvent::Redraw); |
| ... | ... | @@ -170,9 +222,13 @@ enum Replay { |
| 170 | 222 | Input(ui::Event), |
| 171 | 223 | /// Paints the next frame into a PNG as well as the window. |
| 172 | 224 | Snapshot(PathBuf), |
| 225 | /// Writes the window's accessibility tree as text. | |
| 226 | Accessibility(PathBuf), | |
| 173 | 227 | /// A frame during a wait, as a visible window's display would ask for. |
| 174 | 228 | Tick, |
| 175 | 229 | Appearance(winit::window::Theme), |
| 230 | /// Resizes the window's content, in points. | |
| 231 | Resize([f32; 2]), | |
| 176 | 232 | Quit, |
| 177 | 233 | } |
| 178 | 234 | |
| ... | ... | @@ -423,10 +479,20 @@ const HOLD: std::time::Duration = std::time::Duration::from_millis(200); |
| 423 | 479 | enum Command { |
| 424 | 480 | /// Opens a notebook's section by catalog path, at the page it showed last. |
| 425 | 481 | OpenSection(Arc<Library>, String), |
| 482 | /// Asks for a picture for the tag New Tag or Modify Tag edits. | |
| 483 | TagPicture, | |
| 426 | 484 | /// Asks for a notebook folder and opens it. |
| 427 | 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 | 489 | /// Asks where to keep a new notebook and creates it. |
| 429 | 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 | 496 | /// Closes a notebook, keeping its files. |
| 431 | 497 | CloseNotebook(Arc<Library>), |
| 432 | 498 | /// Changes a notebook's sections and groups. |
| ... | ... | @@ -532,8 +598,11 @@ struct State { |
| 532 | 598 | menu: Option<(menus::Target, [f32; 2])>, |
| 533 | 599 | /// The Options dialog's choices while it is open. |
| 534 | 600 | options: Option<options::Options>, |
| 601 | updates: update::Updates, | |
| 535 | 602 | /// The Link dialog's fields while it is open. |
| 536 | 603 | link: Option<link::LinkDialog>, |
| 604 | /// Open Notebook from Server while it is open. | |
| 605 | server: Option<server::Connect>, | |
| 537 | 606 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. |
| 538 | 607 | tags: Vec<canvas::editor::NoteTag>, |
| 539 | 608 | /// The Customize Tags dialog's list while it is open. |
| ... | ... | @@ -542,6 +611,11 @@ struct State { |
| 542 | 611 | text_menu: Option<(canvas::interaction::Context, [f32; 2])>, |
| 543 | 612 | color_scheme: settings::ColorScheme, |
| 544 | 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 | 619 | /// The strip's fill with the window focused and not, continuing the system's title bar. |
| 546 | 620 | titlebar: [[f32; 4]; 2], |
| 547 | 621 | /// A section or group being renamed in the sidebar. |
| ... | ... | @@ -600,11 +674,16 @@ struct State { |
| 600 | 674 | initial_date: Option<u64>, |
| 601 | 675 | occluded: bool, |
| 602 | 676 | ime_allowed: bool, |
| 603 | clipboard: platform::Clipboard, | |
| 677 | clipboard: Clipboard, | |
| 604 | 678 | access_adapter: accesskit_winit::Adapter, |
| 605 | 679 | accessibility: accessibility::Accessibility, |
| 680 | /// Assistive technology holds the page's tree, grafted into the interface's. | |
| 681 | page_grafted: bool, | |
| 606 | 682 | } |
| 607 | 683 | |
| 684 | /// The page's accessibility tree, which the interface's holds at the page's box. | |
| 685 | const PAGE_TREE: accesskit::TreeId = accesskit::TreeId(accesskit::Uuid::from_u128(1)); | |
| 686 | ||
| 608 | 687 | fn strip() -> Id { |
| 609 | 688 | Id::ROOT.child("strip") |
| 610 | 689 | } |
| ... | ... | @@ -614,6 +693,8 @@ fn tab_row() -> Id { |
| 614 | 693 | Id::ROOT.child("tab row") |
| 615 | 694 | } |
| 616 | 695 | |
| 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. | |
| 617 | 698 | fn page() -> Id { |
| 618 | 699 | Id::ROOT.child("page") |
| 619 | 700 | } |
| ... | ... | @@ -683,6 +764,11 @@ impl State { |
| 683 | 764 | library::on_background(move || { |
| 684 | 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 | 772 | let mut notebooks = Vec::new(); |
| 687 | 773 | let mut session = None; |
| 688 | 774 | let mut sectionless = None; |
| ... | ... | @@ -785,7 +871,7 @@ impl State { |
| 785 | 871 | let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); |
| 786 | 872 | // A window shown but never focused hears no focus event; a hidden one draws as focused. |
| 787 | 873 | ui.window_focused = !visible || window.has_focus(); |
| 788 | ui.scrollers = platform::scrollers(); | |
| 874 | platform::system_interface(&mut ui); | |
| 789 | 875 | ui.set_focus(Some(page())); |
| 790 | 876 | for family in FONTS { |
| 791 | 877 | for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { |
| ... | ... | @@ -802,10 +888,18 @@ impl State { |
| 802 | 888 | .collect(); |
| 803 | 889 | fonts.sort_unstable_by_key(|name| name.to_lowercase()); |
| 804 | 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 | 897 | let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into(); |
| 807 | 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 | 903 | author: stored.user_name.unwrap_or_else(platform::user_name), |
| 810 | 904 | window, |
| 811 | 905 | redraw, |
| ... | ... | @@ -832,12 +926,14 @@ impl State { |
| 832 | 926 | title_focus: None, |
| 833 | 927 | menu: None, |
| 834 | 928 | link: None, |
| 929 | server: None, | |
| 835 | 930 | tags: stored |
| 836 | 931 | .tags |
| 837 | 932 | .unwrap_or_else(canvas::editor::NoteTag::defaults), |
| 838 | 933 | tag_list: None, |
| 839 | 934 | text_menu: None, |
| 840 | 935 | options: None, |
| 936 | updates, | |
| 841 | 937 | color_scheme: stored.color_scheme, |
| 842 | 938 | light_pages: stored.light_pages, |
| 843 | 939 | titlebar, |
| ... | ... | @@ -881,7 +977,20 @@ impl State { |
| 881 | 977 | swipe: navigation::Swipe::default(), |
| 882 | 978 | access_adapter, |
| 883 | 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 | 994 | state.title(); |
| 886 | 995 | platform::update_tag_menu(&state.tags); |
| 887 | 996 | Ok(state) |
| ... | ... | @@ -906,6 +1015,7 @@ impl State { |
| 906 | 1015 | [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32), |
| 907 | 1016 | scale, |
| 908 | 1017 | )?; |
| 1018 | self.update_accessibility(false)?; | |
| 909 | 1019 | lap("build", start); |
| 910 | 1020 | self.window.set_cursor( |
| 911 | 1021 | platform::resize_direction(&self.window, self.pointer) |
| ... | ... | @@ -951,8 +1061,10 @@ impl State { |
| 951 | 1061 | /// its box. |
| 952 | 1062 | fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> { |
| 953 | 1063 | self.ui.begin(size, scale, Instant::now()); |
| 1064 | platform::cover_border_line(&mut self.ui, size[0]); | |
| 954 | 1065 | let (section, open_tab, open_page) = self.build()?; |
| 955 | 1066 | self.options_dialog(); |
| 1067 | self.server_dialog(); | |
| 956 | 1068 | self.link_dialog()?; |
| 957 | 1069 | self.customize_tags(); |
| 958 | 1070 | self.palette(); |
| ... | ... | @@ -1151,6 +1263,7 @@ impl State { |
| 1151 | 1263 | TAB_ROW, |
| 1152 | 1264 | theme.strip, |
| 1153 | 1265 | ); |
| 1266 | name(&mut self.ui, row, "Sections"); | |
| 1154 | 1267 | self.drag_tabs(held, slot, settled, row); |
| 1155 | 1268 | let Some(session) = &self.session else { |
| 1156 | 1269 | unreachable!("The tabs are the session's") |
| ... | ... | @@ -1268,9 +1381,13 @@ impl State { |
| 1268 | 1381 | flags: Flags::CUSTOM | Flags::FOCUSABLE, |
| 1269 | 1382 | size: [fill(), fill()], |
| 1270 | 1383 | fill: Some(theme.paper), |
| 1384 | role: Some(accesskit::Role::GenericContainer), | |
| 1271 | 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 | 1391 | self.template_strip(&theme); |
| 1275 | 1392 | if let Some(rect) = self.ui.rect(page()) { |
| 1276 | 1393 | self.transport(&theme, rect)?; |
| ... | ... | @@ -1486,6 +1603,7 @@ impl State { |
| 1486 | 1603 | fill: Some(theme.strip), |
| 1487 | 1604 | pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], |
| 1488 | 1605 | gap: GAP, |
| 1606 | role: Some(accesskit::Role::Toolbar), | |
| 1489 | 1607 | ..Spec::default() |
| 1490 | 1608 | }; |
| 1491 | 1609 | if strip_row { |
| ... | ... | @@ -1529,7 +1647,7 @@ impl State { |
| 1529 | 1647 | /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in |
| 1530 | 1648 | /// taking notes fold last, and those with chords or a place in the menu bar first. |
| 1531 | 1649 | fn tools(&mut self, theme: &Theme) { |
| 1532 | use Entry::{Open, Rule, Run, Soon}; | |
| 1650 | use Entry::{Open, Rule, Run}; | |
| 1533 | 1651 | use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle}; |
| 1534 | 1652 | use commands::{Choice, Id as Cmd}; |
| 1535 | 1653 | use ui::shell::TOOL; |
| ... | ... | @@ -1548,11 +1666,14 @@ impl State { |
| 1548 | 1666 | .map_or_else(commands::Status::default, |(_, status)| *status) |
| 1549 | 1667 | }; |
| 1550 | 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 | 1671 | let fonts = &self.fonts; |
| 1552 | 1672 | let recent_fonts = &self.recent_fonts; |
| 1553 | 1673 | let pens = &self.toolbar; |
| 1554 | 1674 | let engine = &self.view.engine; |
| 1555 | 1675 | let session = self.session.as_ref(); |
| 1676 | let update = self.updates.status(); | |
| 1556 | 1677 | let label = format!("{:.0}%", self.view.zoom() * 100.0); |
| 1557 | 1678 | let drawing_pens = self.pens(); |
| 1558 | 1679 | let ui = &mut self.ui; |
| ... | ... | @@ -1561,7 +1682,7 @@ impl State { |
| 1561 | 1682 | let mut choice = group( |
| 1562 | 1683 | ui, |
| 1563 | 1684 | "navigate", |
| 1564 | 4, | |
| 1685 | 6, | |
| 1565 | 1686 | |ui| { |
| 1566 | 1687 | let mut choice = None; |
| 1567 | 1688 | for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] { |
| ... | ... | @@ -1577,7 +1698,7 @@ impl State { |
| 1577 | 1698 | choice = group( |
| 1578 | 1699 | ui, |
| 1579 | 1700 | "clipboard", |
| 1580 | 3, | |
| 1701 | 5, | |
| 1581 | 1702 | |ui| { |
| 1582 | 1703 | divider(ui, theme); |
| 1583 | 1704 | let mut choice = None; |
| ... | ... | @@ -1587,9 +1708,10 @@ impl State { |
| 1587 | 1708 | } else if ui::shell::split_button( |
| 1588 | 1709 | ui, |
| 1589 | 1710 | "paste", |
| 1711 | title(Cmd::Paste), | |
| 1590 | 1712 | art::PASTE, |
| 1591 | 1713 | None, |
| 1592 | false, | |
| 1714 | None, | |
| 1593 | 1715 | toolbar_popup("paste"), |
| 1594 | 1716 | ) |
| 1595 | 1717 | .clicked |
| ... | ... | @@ -1653,9 +1775,8 @@ impl State { |
| 1653 | 1775 | size: ui::Size::Pixels(120.0), |
| 1654 | 1776 | strictness: 64.0 / 120.0, |
| 1655 | 1777 | }; |
| 1656 | if ui::shell::combo(ui, "font", &font, width).pressed { | |
| 1657 | ui.open_popup(toolbar_popup("font")); | |
| 1658 | } | |
| 1778 | let menu = toolbar_popup("font"); | |
| 1779 | ui::shell::combo(ui, "font", "Font", &font, width, menu, fonts_apply); | |
| 1659 | 1780 | // Each family is named with the substitute it shows in, and picked by its own name; a |
| 1660 | 1781 | // family of None heads a group. Typing searches the full list. |
| 1661 | 1782 | let named = |name: &str| { |
| ... | ... | @@ -1702,9 +1823,8 @@ impl State { |
| 1702 | 1823 | choice = Some(Choice::Font(name.to_owned())); |
| 1703 | 1824 | } |
| 1704 | 1825 | let combo = ui.id("size"); |
| 1705 | if ui::shell::combo(ui, "size", &size, 44.0).pressed { | |
| 1706 | ui.open_popup(toolbar_popup("size")); | |
| 1707 | } | |
| 1826 | let menu = toolbar_popup("size"); | |
| 1827 | ui::shell::combo(ui, "size", "Font Size", &size, 44.0, menu, fonts_apply); | |
| 1708 | 1828 | // A size typed in the field joins the list, in half points as stored. |
| 1709 | 1829 | let mut sizes = SIZES.to_vec(); |
| 1710 | 1830 | if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) |
| ... | ... | @@ -1720,7 +1840,8 @@ impl State { |
| 1720 | 1840 | .iter() |
| 1721 | 1841 | .map(|label| ui::popup::Item { |
| 1722 | 1842 | text: label, |
| 1723 | checked: *label == size, | |
| 1843 | checked: Some(*label == size), | |
| 1844 | current: *label == size, | |
| 1724 | 1845 | ..Default::default() |
| 1725 | 1846 | }) |
| 1726 | 1847 | .collect(); |
| ... | ... | @@ -1737,7 +1858,7 @@ impl State { |
| 1737 | 1858 | choice = group( |
| 1738 | 1859 | ui, |
| 1739 | 1860 | "character", |
| 1740 | 6, | |
| 1861 | 8, | |
| 1741 | 1862 | |ui| { |
| 1742 | 1863 | divider(ui, theme); |
| 1743 | 1864 | let mut choice = None; |
| ... | ... | @@ -1747,7 +1868,7 @@ impl State { |
| 1747 | 1868 | ( |
| 1748 | 1869 | "highlight", |
| 1749 | 1870 | Cmd::Highlight, |
| 1750 | &HIGHLIGHTS.map(|(color, _)| color)[..], | |
| 1871 | &HIGHLIGHTS[..], | |
| 1751 | 1872 | 5, |
| 1752 | 1873 | "No Color", |
| 1753 | 1874 | None, |
| ... | ... | @@ -1763,13 +1884,26 @@ impl State { |
| 1763 | 1884 | ] { |
| 1764 | 1885 | let split = ui.id(part); |
| 1765 | 1886 | let icon = artwork(id).unwrap_or_default(); |
| 1766 | if ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)) | |
| 1767 | .clicked | |
| 1887 | if !status_of(id).enabled { | |
| 1888 | 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 | 1900 | choice = Some(Choice::Command(id)); |
| 1770 | 1901 | } |
| 1771 | 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 | 1907 | let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); |
| 1774 | 1908 | if let Some(chosen) = |
| 1775 | 1909 | ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) |
| ... | ... | @@ -1778,8 +1912,8 @@ impl State { |
| 1778 | 1912 | swatches |
| 1779 | 1913 | .iter() |
| 1780 | 1914 | .zip(&colors) |
| 1781 | .find(|(_, color)| **color == chosen) | |
| 1782 | .map(|(stored, _)| *stored) | |
| 1915 | .find(|(_, (color, _))| *color == chosen) | |
| 1916 | .map(|((stored, _), _)| *stored) | |
| 1783 | 1917 | }); |
| 1784 | 1918 | choice = Some(if id == Cmd::Highlight { |
| 1785 | 1919 | Choice::Highlight(color) |
| ... | ... | @@ -1791,7 +1925,7 @@ impl State { |
| 1791 | 1925 | dropdown( |
| 1792 | 1926 | ui, |
| 1793 | 1927 | "script", |
| 1794 | Head::Menu(art::STRIKETHROUGH), | |
| 1928 | Head::Menu("Text Effects", art::STRIKETHROUGH), | |
| 1795 | 1929 | &[ |
| 1796 | 1930 | Run(Cmd::Toggle(Toggle::Strikethrough)), |
| 1797 | 1931 | Run(Cmd::Toggle(Toggle::Subscript)), |
| ... | ... | @@ -1832,7 +1966,7 @@ impl State { |
| 1832 | 1966 | choice = group( |
| 1833 | 1967 | ui, |
| 1834 | 1968 | "paragraph", |
| 1835 | 5, | |
| 1969 | 7, | |
| 1836 | 1970 | |ui| { |
| 1837 | 1971 | divider(ui, theme); |
| 1838 | 1972 | let mut choice = None; |
| ... | ... | @@ -1844,9 +1978,20 @@ impl State { |
| 1844 | 1978 | "numbering" |
| 1845 | 1979 | }; |
| 1846 | 1980 | let icon = artwork(id).unwrap_or_default(); |
| 1847 | let on = status_of(id).checked; | |
| 1848 | if ui::shell::split_button(ui, part, icon, None, on, toolbar_popup(part)) | |
| 1849 | .clicked | |
| 1981 | let status = status_of(id); | |
| 1982 | if !status.enabled { | |
| 1983 | 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 | 1996 | choice = Some(Choice::Command(id)); |
| 1852 | 1997 | } |
| ... | ... | @@ -1931,10 +2076,17 @@ impl State { |
| 1931 | 2076 | } |
| 1932 | 2077 | let aligned = alignments |
| 1933 | 2078 | .into_iter() |
| 1934 | .find(|id| status_of(*id).checked) | |
| 2079 | .find(|id| status_of(*id).checked == Some(true)) | |
| 1935 | 2080 | .unwrap_or(alignments[0]); |
| 1936 | 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 | 2091 | |ui| { |
| 1940 | 2092 | let mut entries = vec![ |
| ... | ... | @@ -1962,19 +2114,19 @@ impl State { |
| 1962 | 2114 | choice = group( |
| 1963 | 2115 | ui, |
| 1964 | 2116 | "tags", |
| 1965 | 7, | |
| 2117 | 9, | |
| 1966 | 2118 | |ui| { |
| 1967 | 2119 | divider(ui, theme); |
| 1968 | 2120 | let mut choice = None; |
| 1969 | 2121 | for &(place, tag) in tags.iter().take(3) { |
| 1970 | 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 | 2126 | |ui| { |
| 1975 | 2127 | let head = match tags.first() { |
| 1976 | 2128 | Some(&(place, tag)) => Head::Tag(place, tag), |
| 1977 | None => Head::More, | |
| 2129 | None => Head::More("Tags"), | |
| 1978 | 2130 | }; |
| 1979 | 2131 | dropdown(ui, "menu", head, &all_tags, status_of) |
| 1980 | 2132 | }, |
| ... | ... | @@ -2008,17 +2160,25 @@ impl State { |
| 2008 | 2160 | Cmd::RecordAudio, |
| 2009 | 2161 | Cmd::RecordVideo, |
| 2010 | 2162 | ] |
| 2011 | .map(Run); | |
| 2163 | .into_iter() | |
| 2164 | .filter(|id| commands::offered(*id)) | |
| 2165 | .map(Run) | |
| 2166 | .collect::<Vec<_>>(); | |
| 2012 | 2167 | choice = group( |
| 2013 | 2168 | ui, |
| 2014 | 2169 | "insert", |
| 2015 | 2, | |
| 2170 | 4, | |
| 2016 | 2171 | |ui| { |
| 2017 | 2172 | divider(ui, theme); |
| 2018 | 2173 | let mut choice = None; |
| 2019 | 2174 | if status_of(Cmd::Table).enabled { |
| 2020 | let anchor = | |
| 2021 | ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table")); | |
| 2175 | let anchor = ui::shell::menu_button( | |
| 2176 | ui, | |
| 2177 | "table", | |
| 2178 | art::TABLE, | |
| 2179 | None, | |
| 2180 | toolbar_popup("table"), | |
| 2181 | ); | |
| 2022 | 2182 | tip(ui, Cmd::Table); |
| 2023 | 2183 | if let Some([columns, rows]) = |
| 2024 | 2184 | ui::popup::table_picker(ui, toolbar_popup("table"), anchor, [10, 8]) |
| ... | ... | @@ -2027,32 +2187,68 @@ impl State { |
| 2027 | 2187 | } |
| 2028 | 2188 | } else { |
| 2029 | 2189 | ui::shell::unavailable(ui, "table", art::TABLE, text, true); |
| 2190 | tip(ui, Cmd::Table); | |
| 2030 | 2191 | } |
| 2031 | 2192 | for id in inserted { |
| 2032 | 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 | 2196 | entries.push(Rule); |
| 2036 | 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 | 2207 | |ui| { |
| 2040 | 2208 | let mut entries = vec![Open("table", Cmd::Table)]; |
| 2041 | 2209 | entries.extend(inserted.map(Run)); |
| 2042 | 2210 | entries.push(Rule); |
| 2043 | entries.extend(more); | |
| 2211 | entries.extend(more.iter().copied()); | |
| 2044 | 2212 | entries.push(Rule); |
| 2045 | 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 | 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 | 2246 | // Pen draws with the gallery's last pick. Folded, the group leaves its tools to |
| 2051 | 2247 | // Insert's menus, so the narrowest row keeps its width. |
| 2052 | 2248 | choice = group( |
| 2053 | 2249 | ui, |
| 2054 | 2250 | "draw", |
| 2055 | 1, | |
| 2251 | 2, | |
| 2056 | 2252 | |ui| { |
| 2057 | 2253 | divider(ui, theme); |
| 2058 | 2254 | let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType)); |
| ... | ... | @@ -2064,6 +2260,7 @@ impl State { |
| 2064 | 2260 | if ui::shell::split_button( |
| 2065 | 2261 | ui, |
| 2066 | 2262 | "pen", |
| 2263 | title(Cmd::Pen), | |
| 2067 | 2264 | art::PEN, |
| 2068 | 2265 | bar, |
| 2069 | 2266 | status.checked, |
| ... | ... | @@ -2080,11 +2277,19 @@ impl State { |
| 2080 | 2277 | } |
| 2081 | 2278 | } else { |
| 2082 | 2279 | ui::shell::unavailable(ui, "pen", art::PEN, text, true); |
| 2280 | tip(ui, Cmd::Pen); | |
| 2083 | 2281 | } |
| 2084 | 2282 | for id in [Cmd::Eraser, Cmd::Lasso] { |
| 2085 | 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 | 2294 | |_| None, |
| 2090 | 2295 | ) |
| ... | ... | @@ -2097,21 +2302,21 @@ impl State { |
| 2097 | 2302 | }, |
| 2098 | 2303 | ); |
| 2099 | 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 | 2307 | let views = [ |
| 2104 | 2308 | Run(Cmd::Sidebar), |
| 2105 | 2309 | Run(Cmd::PageList), |
| 2106 | 2310 | Run(Cmd::DarkPages), |
| 2107 | 2311 | Run(Cmd::FullPageView), |
| 2108 | 2312 | Rule, |
| 2109 | Soon("Spelling", Some(art::SPELLING)), | |
| 2313 | Run(Cmd::HideSpelling), | |
| 2314 | Run(Cmd::Spelling), | |
| 2110 | 2315 | ]; |
| 2111 | 2316 | choice = group( |
| 2112 | 2317 | ui, |
| 2113 | 2318 | "view", |
| 2114 | 0, | |
| 2319 | 1, | |
| 2115 | 2320 | |ui| { |
| 2116 | 2321 | divider(ui, theme); |
| 2117 | 2322 | let mut choice = None; |
| ... | ... | @@ -2120,6 +2325,7 @@ impl State { |
| 2120 | 2325 | ui, |
| 2121 | 2326 | "page color", |
| 2122 | 2327 | art::PAGE_COLOR, |
| 2328 | None, | |
| 2123 | 2329 | toolbar_popup("page color"), |
| 2124 | 2330 | ); |
| 2125 | 2331 | tip(ui, Cmd::PageColor); |
| ... | ... | @@ -2140,6 +2346,7 @@ impl State { |
| 2140 | 2346 | .or(choice.take()); |
| 2141 | 2347 | } else { |
| 2142 | 2348 | ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true); |
| 2349 | tip(ui, Cmd::PageColor); | |
| 2143 | 2350 | } |
| 2144 | 2351 | choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice); |
| 2145 | 2352 | let level = ui.leaf( |
| ... | ... | @@ -2151,6 +2358,7 @@ impl State { |
| 2151 | 2358 | hover_fill: Some(theme.hover()), |
| 2152 | 2359 | radius: 4.0, |
| 2153 | 2360 | center: true, |
| 2361 | role: Some(accesskit::Role::Button), | |
| 2154 | 2362 | ..Spec::default() |
| 2155 | 2363 | }, |
| 2156 | 2364 | ); |
| ... | ... | @@ -2158,8 +2366,18 @@ impl State { |
| 2158 | 2366 | choice = Some(Choice::Command(Cmd::ActualSize)); |
| 2159 | 2367 | } |
| 2160 | 2368 | tip(ui, Cmd::ActualSize); |
| 2369 | if let Some(node) = ui.access(ui.id("level")) { | |
| 2370 | node.set_value(label.as_str()); | |
| 2371 | } | |
| 2161 | 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 | 2382 | |ui| { |
| 2165 | 2383 | let mut entries = vec![ |
| ... | ... | @@ -2171,7 +2389,13 @@ impl State { |
| 2171 | 2389 | Rule, |
| 2172 | 2390 | ]; |
| 2173 | 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 | 2401 | .or(choice); |
| ... | ... | @@ -2204,8 +2428,14 @@ impl State { |
| 2204 | 2428 | ); |
| 2205 | 2429 | if folded { |
| 2206 | 2430 | let open = self.ui.popup_open(search::results()); |
| 2207 | if ui::shell::tool_button(&mut self.ui, "search", art::SEARCH, theme.text, open) | |
| 2208 | .pressed | |
| 2431 | if ui::shell::tool_button( | |
| 2432 | &mut self.ui, | |
| 2433 | "search", | |
| 2434 | art::SEARCH, | |
| 2435 | theme.text, | |
| 2436 | Some(open), | |
| 2437 | ) | |
| 2438 | .pressed | |
| 2209 | 2439 | { |
| 2210 | 2440 | self.start_search(); |
| 2211 | 2441 | } |
| ... | ... | @@ -2213,7 +2443,7 @@ impl State { |
| 2213 | 2443 | } else if let Err(error) = self.search_box(theme) { |
| 2214 | 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 | 2447 | self.commands.push(Command::NewPage { under: None }); |
| 2218 | 2448 | } |
| 2219 | 2449 | tip(&mut self.ui, commands::Id::NewPage); |
| ... | ... | @@ -2222,7 +2452,7 @@ impl State { |
| 2222 | 2452 | } else { |
| 2223 | 2453 | 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 | 2456 | self.pages_open = !self.pages_open; |
| 2227 | 2457 | } |
| 2228 | 2458 | tip(&mut self.ui, commands::Id::PageList); |
| ... | ... | @@ -2244,9 +2474,14 @@ impl State { |
| 2244 | 2474 | flags: Flags::SCROLL | Flags::CLIP, |
| 2245 | 2475 | axis: Axis::Y, |
| 2246 | 2476 | size: [px(width), fill()], |
| 2477 | role: Some(accesskit::Role::TabList), | |
| 2247 | 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 | 2485 | let found = self |
| 2251 | 2486 | .search |
| 2252 | 2487 | .found_in(&session.library.key(&session.tabs[session.tab].path)); |
| ... | ... | @@ -2298,6 +2533,10 @@ impl State { |
| 2298 | 2533 | for event in events { |
| 2299 | 2534 | let response = match event { |
| 2300 | 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 | 2540 | ui::Event::PointerLeft => self.view.pointer_left(), |
| 2302 | 2541 | ui::Event::Button { |
| 2303 | 2542 | button: MouseButton::Left, |
| ... | ... | @@ -2327,7 +2566,9 @@ impl State { |
| 2327 | 2566 | self.open_text_menu()?; |
| 2328 | 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 | 2572 | // A conflict page takes no typing; commands and caret moves still apply. |
| 2332 | 2573 | ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue, |
| 2333 | 2574 | ui::Event::Key { ref key, .. } |
| ... | ... | @@ -2445,7 +2686,11 @@ impl State { |
| 2445 | 2686 | self.open_path(&path); |
| 2446 | 2687 | } |
| 2447 | 2688 | } |
| 2689 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), | |
| 2448 | 2690 | Command::NewNotebook => self.new_notebook()?, |
| 2691 | Command::UseICloud => self.use_icloud()?, | |
| 2692 | #[cfg(target_os = "linux")] | |
| 2693 | Command::Install => desktop::install(), | |
| 2449 | 2694 | Command::CloseNotebook(library) => self.close_notebook(&library), |
| 2450 | 2695 | Command::Structure(library, change) => self.restructure(library, change), |
| 2451 | 2696 | // The index follows the section's page list. |
| ... | ... | @@ -2475,6 +2720,7 @@ impl State { |
| 2475 | 2720 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, |
| 2476 | 2721 | Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, |
| 2477 | 2722 | Command::Choose(choice) => self.run(choice)?, |
| 2723 | Command::TagPicture => self.pick_tag_picture(), | |
| 2478 | 2724 | } |
| 2479 | 2725 | Ok(()) |
| 2480 | 2726 | } |
| ... | ... | @@ -2541,7 +2787,7 @@ impl State { |
| 2541 | 2787 | return Ok(()); |
| 2542 | 2788 | } |
| 2543 | 2789 | // A recording goes on the page it started on. |
| 2544 | self.stop_recording()?; | |
| 2790 | self.stop_recording(true)?; | |
| 2545 | 2791 | // The page shown so far keeps what was typed while the next one loaded. |
| 2546 | 2792 | self.persist()?; |
| 2547 | 2793 | if let Some(session) = &self.session { |
| ... | ... | @@ -2692,7 +2938,7 @@ impl State { |
| 2692 | 2938 | self.ui.set_focus(Some(page())); |
| 2693 | 2939 | } |
| 2694 | 2940 | self.title(); |
| 2695 | self.update_accessibility()?; | |
| 2941 | self.update_accessibility(true)?; | |
| 2696 | 2942 | self.window.request_redraw(); |
| 2697 | 2943 | Ok(()) |
| 2698 | 2944 | } |
| ... | ... | @@ -2719,7 +2965,7 @@ impl State { |
| 2719 | 2965 | LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale), |
| 2720 | 2966 | ); |
| 2721 | 2967 | let start = Instant::now(); |
| 2722 | self.update_accessibility()?; | |
| 2968 | self.update_accessibility(true)?; | |
| 2723 | 2969 | lap("accessibility", start); |
| 2724 | 2970 | Ok(()) |
| 2725 | 2971 | } |
| ... | ... | @@ -2841,6 +3087,24 @@ impl State { |
| 2841 | 3087 | Ok(()) |
| 2842 | 3088 | } |
| 2843 | 3089 | |
| 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 | 3108 | /// Hands the section the ops the editor recorded since the last call, as one edit; the |
| 2845 | 3109 | /// section thread stores it. |
| 2846 | 3110 | fn persist(&mut self) -> Result<(), Box<dyn Error>> { |
| ... | ... | @@ -3001,52 +3265,150 @@ impl State { |
| 3001 | 3265 | Ok(()) |
| 3002 | 3266 | } |
| 3003 | 3267 | |
| 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 | 3295 | let mut error = None; |
| 3006 | let view = &self.view; | |
| 3007 | let corner = self.ui.rect(page()).unwrap_or_default(); | |
| 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 | }; | |
| 3296 | let mut sent = false; | |
| 3297 | let (view, ui, window) = (&self.view, &self.ui, &self.window); | |
| 3016 | 3298 | self.access_adapter.update_if_active(|| { |
| 3017 | match self.accessibility.update( | |
| 3018 | &view.editor, | |
| 3019 | view.scene.as_ref(), | |
| 3020 | viewport, | |
| 3021 | &self.window.title(), | |
| 3022 | view.outline_preview(), | |
| 3023 | view.object_focus().and_then(|focus| focus.read_only()), | |
| 3024 | ) { | |
| 3025 | Ok(update) => update, | |
| 3026 | Err(failure) => { | |
| 3027 | error = Some(failure); | |
| 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 | } | |
| 3299 | sent = true; | |
| 3300 | page_tree(view, ui, window, &mut self.accessibility).unwrap_or_else(|failure| { | |
| 3301 | error = Some(failure); | |
| 3302 | self.accessibility.deactivate(); | |
| 3303 | let mut root = accesskit::Node::new(accesskit::Role::Group); | |
| 3304 | root.set_label("Page"); | |
| 3305 | accesskit::TreeUpdate { | |
| 3306 | nodes: vec![(accessibility::ROOT, root)], | |
| 3307 | tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)), | |
| 3308 | tree_id: PAGE_TREE, | |
| 3309 | focus: accessibility::ROOT, | |
| 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 | 3323 | if let Some(error) = error { |
| 3041 | 3324 | return Err(error.into()); |
| 3042 | 3325 | } |
| 3043 | 3326 | Ok(()) |
| 3044 | 3327 | } |
| 3045 | 3328 | |
| 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 | 3403 | fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> { |
| 3047 | 3404 | trace_input(&request); |
| 3048 | 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 | 3412 | return Ok(()); |
| 3051 | 3413 | } |
| 3052 | 3414 | if let Some(field) = self.accessibility.date_for_node(request.target_node) { |
| ... | ... | @@ -3143,6 +3505,12 @@ impl State { |
| 3143 | 3505 | self.window.scale_factor() as f32 * (most / longest).min(1.0) |
| 3144 | 3506 | } |
| 3145 | 3507 | |
| 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 | 3514 | /// The paper the open page lies on: the theme's, in the page's colour. |
| 3147 | 3515 | fn paper(&self) -> canvas::gpu::Paper { |
| 3148 | 3516 | canvas::gpu::Paper { |
| ... | ... | @@ -3157,6 +3525,9 @@ impl State { |
| 3157 | 3525 | let start = Instant::now(); |
| 3158 | 3526 | let paper = self.paper(); |
| 3159 | 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 | 3531 | let theme = &self.ui.theme; |
| 3161 | 3532 | let page_primitives = self.view.primitives(TextColors { |
| 3162 | 3533 | caret: theme.caret, |
| ... | ... | @@ -3281,7 +3652,7 @@ impl State { |
| 3281 | 3652 | pressed: true, |
| 3282 | 3653 | at, |
| 3283 | 3654 | } = 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 | 3657 | let double = self.strip_press.is_some_and(|last| { |
| 3287 | 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 | 3699 | } |
| 3329 | 3700 | } |
| 3330 | 3701 | |
| 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. | |
| 3705 | fn 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 | 3733 | /// A section's colour as linear RGBA; sections without one take OneNote's default blue. |
| 3332 | 3734 | fn section_color(color: Option<u32>) -> [f32; 4] { |
| 3333 | 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 | 3748 | hover_fill: Some(draw::srgb(0xff, 0xe4, 0xa6)), |
| 3347 | 3749 | pad: [12.0, 6.0], |
| 3348 | 3750 | gap: 8.0, |
| 3751 | role: Some(accesskit::Role::Button), | |
| 3349 | 3752 | ..Spec::default() |
| 3350 | 3753 | }, |
| 3351 | 3754 | ); |
| ... | ... | @@ -3770,6 +4173,7 @@ fn page_tab( |
| 3770 | 4173 | pad: [pad, 0.0], |
| 3771 | 4174 | offset: [0.0, tab.shift], |
| 3772 | 4175 | shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]), |
| 4176 | role: Some(accesskit::Role::Tab), | |
| 3773 | 4177 | ..Spec::default() |
| 3774 | 4178 | }; |
| 3775 | 4179 | let (spec, color) = if selected { |
| ... | ... | @@ -3805,6 +4209,10 @@ fn page_tab( |
| 3805 | 4209 | (spec, color) |
| 3806 | 4210 | }; |
| 3807 | 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 | 4216 | let kept = match tab.renaming { |
| 3809 | 4217 | Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP), |
| 3810 | 4218 | None => { |
| ... | ... | @@ -3924,17 +4332,23 @@ fn pen_gallery( |
| 3924 | 4332 | } else { |
| 3925 | 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 | 4341 | ui.leaf( |
| 3929 | 4342 | "stroke", |
| 3930 | 4343 | Spec { |
| 3931 | size: [fill(), fill()], | |
| 4344 | size: [fill(), px(tall)], | |
| 3932 | 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 | 4347 | radius: tall / 2.0, |
| 3935 | 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 | 4391 | Run(commands::Id), |
| 3978 | 4392 | /// Applies the tag at this place in the tag list. |
| 3979 | 4393 | Tag(usize, &'a canvas::editor::NoteTag), |
| 3980 | /// Opens the toolbar's popup `name`, which applies the command, under the button the | |
| 3981 | /// popup's own control would open it from, or where that is folded away, this menu's. | |
| 4394 | /// Opens the toolbar's popup `name`, which applies the command, as a submenu. | |
| 3982 | 4395 | Open(&'static str, commands::Id), |
| 3983 | /// A command not built yet, shown disabled. | |
| 3984 | Soon(&'static str, Option<&'static [&'static str]>), | |
| 3985 | 4396 | /// Rules off the entries after it. |
| 3986 | 4397 | Rule, |
| 3987 | 4398 | } |
| 3988 | 4399 | |
| 3989 | 4400 | /// 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. | |
| 3991 | 4402 | enum Head<'a> { |
| 3992 | 4403 | Split(commands::Id), |
| 3993 | 4404 | /// A button applying the tag at this place in the tag list. |
| 3994 | 4405 | Tag(usize, &'a canvas::editor::NoteTag), |
| 3995 | Menu(&'static [&'static str]), | |
| 3996 | More, | |
| 4406 | Menu(&'static str, &'static [&'static str]), | |
| 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 | } |
| 3998 | 4411 | |
| 3999 | 4412 | /// A toolbar menu `part` of `entries` opened by `head`, whose command enabled and checked as |
| ... | ... | @@ -4007,13 +4420,19 @@ fn dropdown( |
| 4007 | 4420 | ) -> Option<commands::Choice> { |
| 4008 | 4421 | use commands::Choice; |
| 4009 | 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 | 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 | 4431 | let anchor = match head { |
| 4013 | 4432 | Head::Split(id) if status_of(id).enabled => { |
| 4014 | 4433 | let icon = artwork(id).unwrap_or_default(); |
| 4015 | 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 | 4436 | choice = Some(Choice::Command(id)); |
| 4018 | 4437 | } |
| 4019 | 4438 | tip(ui, id); |
| ... | ... | @@ -4021,22 +4440,62 @@ fn dropdown( |
| 4021 | 4440 | } |
| 4022 | 4441 | Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => { |
| 4023 | 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 | 4445 | choice = Some(Choice::Command(commands::Id::Tag(place))); |
| 4026 | 4446 | } |
| 4027 | 4447 | tag_tip(ui, place, tag); |
| 4028 | 4448 | ui::Anchor::Below(ui.rect(button).unwrap_or_default()) |
| 4029 | 4449 | } |
| 4030 | 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), | |
| 4032 | Head::Tag(_, tag) => ui::shell::menu_button(ui, part, tag_art(tag), menu), | |
| 4033 | Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu), | |
| 4034 | Head::More => { | |
| 4451 | Head::Split(id) => { | |
| 4452 | let icon = artwork(id).unwrap_or_default(); | |
| 4453 | let anchor = ui::shell::menu_button(ui, part, icon, None, menu); | |
| 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 | 4488 | let open = ui.popup_open(menu); |
| 4036 | 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 | 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 | 4499 | ui::Anchor::Below(ui.rect(button).unwrap_or_default()) |
| 4041 | 4500 | } |
| 4042 | 4501 | }; |
| ... | ... | @@ -4048,9 +4507,24 @@ fn dropdown( |
| 4048 | 4507 | _ => String::new(), |
| 4049 | 4508 | }) |
| 4050 | 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 | 4525 | let (mut items, mut actions) = (Vec::new(), Vec::new()); |
| 4052 | 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 | 4528 | let item = match *entry { |
| 4055 | 4529 | Entry::Rule => { |
| 4056 | 4530 | separated = true; |
| ... | ... | @@ -4066,28 +4540,26 @@ fn dropdown( |
| 4066 | 4540 | }, |
| 4067 | 4541 | Entry::Tag(place, tag) => ui::popup::Item { |
| 4068 | 4542 | text: &tag.label, |
| 4069 | icon: TagIcon::of(tag.shape, false).map(tag_sources), | |
| 4543 | icon: tags::artwork(tag), | |
| 4070 | 4544 | ink: tag.color.map(colorref), |
| 4071 | 4545 | highlight: tag.highlight.map(colorref), |
| 4072 | colored: true, | |
| 4546 | tint: Some([1.0; 4]), | |
| 4073 | 4547 | shortcut: key, |
| 4074 | 4548 | checked: status_of(commands::Id::Tag(place)).checked, |
| 4075 | 4549 | disabled: !status_of(commands::Id::Tag(place)).enabled, |
| 4076 | 4550 | ..Default::default() |
| 4077 | 4551 | }, |
| 4078 | 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 | 4558 | icon: artwork(id), |
| 4081 | shortcut: "›", | |
| 4559 | submenu: true, | |
| 4082 | 4560 | disabled: !status_of(id).enabled, |
| 4083 | 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 | 4564 | items.push(ui::popup::Item { |
| 4093 | 4565 | separated: std::mem::take(&mut separated), |
| ... | ... | @@ -4095,17 +4567,25 @@ fn dropdown( |
| 4095 | 4567 | }); |
| 4096 | 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 | 4576 | Some(Entry::Run(id)) => Some(Choice::Command(id)), |
| 4100 | 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 | 4578 | _ => choice, |
| 4106 | 4579 | } |
| 4107 | 4580 | } |
| 4108 | 4581 | |
| 4582 | /// Names box `id` to assistive technology, where no tooltip does. | |
| 4583 | fn 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 | 4589 | /// A tool button for command `id`, lit while `status` checks it and faded while it does not |
| 4110 | 4590 | /// apply; returns the command when clicked. |
| 4111 | 4591 | fn 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 | 4596 | let tint = ui.theme.text; |
| 4117 | 4597 | if !status.enabled { |
| 4118 | 4598 | ui::shell::unavailable(ui, part, icon, tint, false); |
| 4599 | toggled(ui, part, status); | |
| 4600 | tip(ui, id); | |
| 4119 | 4601 | return None; |
| 4120 | 4602 | } |
| 4121 | 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 | 4605 | clicked.then_some(commands::Choice::Command(id)) |
| 4124 | 4606 | } |
| 4125 | 4607 | |
| 4608 | /// Keeps a faded toggle `part` exposed as one, whether or not it is on. | |
| 4609 | fn 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 | 4617 | /// A tool button applying the tag at `place` in the tag list, as `tool` builds a command's. |
| 4127 | 4618 | fn tag_tool( |
| 4128 | 4619 | ui: &mut Ui, |
| ... | ... | @@ -4135,6 +4626,8 @@ fn tag_tool( |
| 4135 | 4626 | let tint = [1.0; 4]; |
| 4136 | 4627 | if !status.enabled { |
| 4137 | 4628 | ui::shell::unavailable(ui, part, tag_art(tag), tint, false); |
| 4629 | toggled(ui, part, status); | |
| 4630 | tag_tip(ui, place, tag); | |
| 4138 | 4631 | return None; |
| 4139 | 4632 | } |
| 4140 | 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 | 4689 | Cmd::PageColor => art::PAGE_COLOR, |
| 4197 | 4690 | Cmd::ZoomIn => art::ZOOM_IN, |
| 4198 | 4691 | Cmd::ZoomOut => art::ZOOM_OUT, |
| 4692 | Cmd::HideSpelling | Cmd::Spelling => art::SPELLING, | |
| 4199 | 4693 | _ => return None, |
| 4200 | 4694 | }) |
| 4201 | 4695 | } |
| ... | ... | @@ -4208,8 +4702,8 @@ fn tag_art(tag: &canvas::editor::NoteTag) -> &'static [&'static str] { |
| 4208 | 4702 | // Artwork is borrowed for the program's life, so each colour pair is made once. |
| 4209 | 4703 | type Chips = BTreeMap<(Option<u32>, Option<u32>), &'static [&'static str]>; |
| 4210 | 4704 | static CHIPS: Mutex<Chips> = Mutex::new(BTreeMap::new()); |
| 4211 | if let Some(icon) = TagIcon::of(tag.shape, false) { | |
| 4212 | return tag_sources(icon); | |
| 4705 | if let Some(art) = tags::artwork(tag) { | |
| 4706 | return art; | |
| 4213 | 4707 | } |
| 4214 | 4708 | let hex = |colorref: u32| { |
| 4215 | 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 | 4737 | ui::popup::tooltip(ui, &tag.label, &key, None); |
| 4244 | 4738 | } |
| 4245 | 4739 | |
| 4246 | /// A tooltip naming command `id` and its chord on the box built last. It names the command, | |
| 4247 | /// not a dialog it opens, so it leaves off the menu's ellipsis. | |
| 4740 | /// A tooltip naming command `id` and its chord on the box built last. | |
| 4248 | 4741 | fn tip(ui: &mut Ui, id: commands::Id) { |
| 4249 | let title = commands::command(id).title.trim_end_matches('…'); | |
| 4250 | ui::popup::tooltip(ui, title, &commands::shortcut(id), None); | |
| 4742 | ui::popup::tooltip(ui, title(id), &commands::shortcut(id), None); | |
| 4743 | } | |
| 4744 | ||
| 4745 | /// Command `id`'s title without the menu's ellipsis, naming the command, not a dialog it opens. | |
| 4746 | fn title(id: commands::Id) -> &'static str { | |
| 4747 | commands::command(id).title.trim_end_matches('…') | |
| 4251 | 4748 | } |
| 4252 | 4749 | |
| 4253 | 4750 | /// The line leading a toolbar group, as far from its buttons as the groups are apart. |
| ... | ... | @@ -4369,11 +4866,18 @@ impl App { |
| 4369 | 4866 | "Keep Editing", |
| 4370 | 4867 | "Discard Changes", |
| 4371 | 4868 | ) { |
| 4869 | if let Some(state) = &self.state { | |
| 4870 | state.publish_now(QUIT_PUBLISH); | |
| 4871 | } | |
| 4372 | 4872 | event_loop.exit(); |
| 4373 | 4873 | } |
| 4374 | 4874 | } |
| 4375 | 4875 | } |
| 4376 | 4876 | |
| 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. | |
| 4879 | const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); | |
| 4880 | ||
| 4377 | 4881 | impl ApplicationHandler<UserEvent> for App { |
| 4378 | 4882 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { |
| 4379 | 4883 | let event = match event { |
| ... | ... | @@ -4404,6 +4908,20 @@ impl ApplicationHandler<UserEvent> for App { |
| 4404 | 4908 | self.close(event_loop); |
| 4405 | 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 | 4925 | UserEvent::Sync => { |
| 4408 | 4926 | if let Some(state) = &mut self.state |
| 4409 | 4927 | && let Err(error) = state.synced() |
| ... | ... | @@ -4436,11 +4954,21 @@ impl ApplicationHandler<UserEvent> for App { |
| 4436 | 4954 | match replay { |
| 4437 | 4955 | Replay::Input(event) => state.input(event), |
| 4438 | 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 | 4962 | Replay::Tick => {} |
| 4440 | 4963 | Replay::Appearance(appearance) => { |
| 4441 | 4964 | state.window.set_theme(Some(appearance)); |
| 4442 | 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 | 4972 | Replay::Quit => { |
| 4445 | 4973 | self.close(event_loop); |
| 4446 | 4974 | return; |
| ... | ... | @@ -4464,12 +4992,12 @@ impl ApplicationHandler<UserEvent> for App { |
| 4464 | 4992 | let was_marked = state.view.editor.marked_range().is_some(); |
| 4465 | 4993 | let result = match event.window_event { |
| 4466 | 4994 | accesskit_winit::WindowEvent::InitialTreeRequested => { |
| 4467 | state.accessibility.deactivate(); | |
| 4468 | state.update_accessibility() | |
| 4995 | state.deactivate_accessibility(); | |
| 4996 | state.update_accessibility(true) | |
| 4469 | 4997 | } |
| 4470 | 4998 | accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), |
| 4471 | 4999 | accesskit_winit::WindowEvent::AccessibilityDeactivated => { |
| 4472 | state.accessibility.deactivate(); | |
| 5000 | state.deactivate_accessibility(); | |
| 4473 | 5001 | Ok(()) |
| 4474 | 5002 | } |
| 4475 | 5003 | }; |
| ... | ... | @@ -4525,7 +5053,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 4525 | 5053 | } |
| 4526 | 5054 | if matches!(event, WindowEvent::CloseRequested) { |
| 4527 | 5055 | 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 | 5058 | eprintln!("{error}"); |
| 4531 | 5059 | } |
| ... | ... | @@ -4560,6 +5088,10 @@ impl ApplicationHandler<UserEvent> for App { |
| 4560 | 5088 | } |
| 4561 | 5089 | WindowEvent::Focused(focused) => { |
| 4562 | 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 | 5095 | state.window.request_redraw(); |
| 4564 | 5096 | } |
| 4565 | 5097 | WindowEvent::Occluded(occluded) => { |
| ... | ... | @@ -4575,6 +5107,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 4575 | 5107 | } |
| 4576 | 5108 | WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft), |
| 4577 | 5109 | WindowEvent::CursorMoved { position, .. } => { |
| 5110 | state.input(ui::Event::Pressure(platform::pen_pressure())); | |
| 4578 | 5111 | state.input(ui::Event::PointerMoved([ |
| 4579 | 5112 | position.x as f32 / scale, |
| 4580 | 5113 | position.y as f32 / scale, |
| ... | ... | @@ -4584,11 +5117,14 @@ impl ApplicationHandler<UserEvent> for App { |
| 4584 | 5117 | state: pressed, |
| 4585 | 5118 | button, |
| 4586 | 5119 | .. |
| 4587 | } => state.input(ui::Event::Button { | |
| 4588 | button, | |
| 4589 | pressed: pressed == ElementState::Pressed, | |
| 4590 | at: Instant::now(), | |
| 4591 | }), | |
| 5120 | } => { | |
| 5121 | state.input(ui::Event::Pressure(platform::pen_pressure())); | |
| 5122 | state.input(ui::Event::Button { | |
| 5123 | button, | |
| 5124 | pressed: pressed == ElementState::Pressed, | |
| 5125 | at: Instant::now(), | |
| 5126 | }) | |
| 5127 | } | |
| 4592 | 5128 | WindowEvent::MouseWheel { delta, phase, .. } => { |
| 4593 | 5129 | if let MouseScrollDelta::PixelDelta(p) = delta { |
| 4594 | 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 | 5179 | if repaint || wake.is_some_and(|wake| wake <= now) { |
| 4644 | 5180 | state.window.request_redraw(); |
| 4645 | 5181 | } |
| 4646 | let next = [ | |
| 4647 | blink, | |
| 4648 | wake.filter(|wake| *wake > now), | |
| 4649 | ] | |
| 4650 | .into_iter() | |
| 4651 | .flatten() | |
| 4652 | .min(); | |
| 5182 | let next = [blink, wake.filter(|wake| *wake > now)] | |
| 5183 | .into_iter() | |
| 5184 | .flatten() | |
| 5185 | .min(); | |
| 4653 | 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 | 5200 | /// Feeds a development script to the window from another thread, one command per line |
| 4668 | 5201 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type |
| 4669 | 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`. | |
| 4671 | 5204 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 4672 | 5205 | let mut steps = Vec::new(); |
| 4673 | 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 | 5224 | "wheel" => Ok(Replay::Input(ui::Event::Wheel( |
| 4692 | 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 | 5231 | "press" => Ok(Replay::Input(button(true))), |
| 4695 | 5232 | "release" => Ok(Replay::Input(button(false))), |
| 4696 | 5233 | "key" => Ok(Replay::Input(ui::Event::Key { |
| ... | ... | @@ -4699,6 +5236,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 4699 | 5236 | "Enter" => Key::Named(NamedKey::Enter), |
| 4700 | 5237 | "Backspace" => Key::Named(NamedKey::Backspace), |
| 4701 | 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 | 5243 | "Left" => Key::Named(NamedKey::ArrowLeft), |
| 4703 | 5244 | "Right" => Key::Named(NamedKey::ArrowRight), |
| 4704 | 5245 | "Up" => Key::Named(NamedKey::ArrowUp), |
| ... | ... | @@ -4722,7 +5263,13 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 4722 | 5263 | ))), |
| 4723 | 5264 | "wait" => Err(std::time::Duration::from_millis(rest.parse()?)), |
| 4724 | 5265 | "quit" => Ok(Replay::Quit), |
| 5266 | "resize" => Ok(Replay::Resize( | |
| 5267 | numbers()? | |
| 5268 | .try_into() | |
| 5269 | .map_err(|_| "resize takes WIDTH HEIGHT")?, | |
| 5270 | )), | |
| 4725 | 5271 | "snapshot" => Ok(Replay::Snapshot(rest.into())), |
| 5272 | "accessibility" => Ok(Replay::Accessibility(rest.into())), | |
| 4726 | 5273 | "appearance" => Ok(Replay::Appearance(match rest { |
| 4727 | 5274 | "light" => winit::window::Theme::Light, |
| 4728 | 5275 | "dark" => winit::window::Theme::Dark, |
| ... | ... | @@ -4760,6 +5307,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 4760 | 5307 | } |
| 4761 | 5308 | |
| 4762 | 5309 | fn main() -> Result<(), Box<dyn Error>> { |
| 5310 | platform::with_pool(launch) | |
| 5311 | } | |
| 5312 | ||
| 5313 | fn 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 | 5318 | let mut args = std::env::args_os().skip(1); |
| 4764 | 5319 | let mut positional = Vec::new(); |
| 4765 | 5320 | let mut substitutes = Vec::new(); |
| ... | ... | @@ -4897,6 +5452,9 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 4897 | 5452 | reference, |
| 4898 | 5453 | } |
| 4899 | 5454 | }; |
| 5455 | if screenshot.is_some() { | |
| 5456 | screenshot::prepare(); | |
| 5457 | } | |
| 4900 | 5458 | let event_loop = platform::event_loop(screenshot.is_some())?; |
| 4901 | 5459 | if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { |
| 4902 | 5460 | replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; |
| ... | ... | @@ -4916,6 +5474,13 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 4916 | 5474 | startup_error: None, |
| 4917 | 5475 | }; |
| 4918 | 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 | 5484 | app.startup_error.map_or(Ok(()), Err) |
| 4920 | 5485 | } |
| 4921 | 5486 |
crates/snowbound/src/manage.rs+69-9| ... | ... | @@ -39,10 +39,6 @@ pub enum Structure { |
| 39 | 39 | }, |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | /// The colour OneNote 2010 gave each new notebook beside its default one, COLORREF | |
| 43 | /// (`corpus/notebook-management/native/new-notebook`). | |
| 44 | const NOTEBOOK_COLOR: u32 = 0x00aeba91; | |
| 45 | ||
| 46 | 42 | /// `current`, the path of a section, after `from` moved to `to`: a section moved itself, |
| 47 | 43 | /// or one inside a moved group. |
| 48 | 44 | fn follow(current: &str, from: &str, to: &str) -> String { |
| ... | ... | @@ -168,16 +164,44 @@ impl State { |
| 168 | 164 | self.refresh() |
| 169 | 165 | } |
| 170 | 166 | |
| 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 | 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 | 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 | 200 | let page = self.dated_page(None)?; |
| 177 | 201 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); |
| 178 | 202 | self.load(move || { |
| 179 | 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 | 205 | let library = Arc::new(Library::created(&location, notebook, &cache)); |
| 182 | 206 | let path = library |
| 183 | 207 | .first_section() |
| ... | ... | @@ -189,6 +213,42 @@ impl State { |
| 189 | 213 | Ok(()) |
| 190 | 214 | } |
| 191 | 215 | |
| 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 | 252 | /// Closes `library`: its files stay, its offline copies go unless edits wait in them, |
| 193 | 253 | /// and the sidebar and the next launch leave it out. |
| 194 | 254 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { |
| ... | ... | @@ -584,7 +644,7 @@ mod tests { |
| 584 | 644 | std::fs::create_dir_all(&temporary).unwrap(); |
| 585 | 645 | let root = temporary.join("Managed"); |
| 586 | 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 | 648 | for (folder, name) in [("", "New Section 2"), ("", "Binned")] { |
| 589 | 649 | notebook.create_section(folder, name, &dated()).unwrap(); |
| 590 | 650 | } |
| ... | ... | @@ -816,7 +876,7 @@ mod tests { |
| 816 | 876 | let root = temporary.join("Emptied"); |
| 817 | 877 | let location = root.to_str().unwrap(); |
| 818 | 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 | 880 | notebook.create_group("", "Group").unwrap(); |
| 821 | 881 | notebook.create_section("Group", "Inner", &dated()).unwrap(); |
| 822 | 882 |
crates/snowbound/src/media_linux.rs+5-5| ... | ... | @@ -351,14 +351,14 @@ impl Recorder { |
| 351 | 351 | self.0.set(if paused { PAUSED } else { PLAYING }); |
| 352 | 352 | } |
| 353 | 353 | |
| 354 | /// Ends the recording, its file complete: the end travels down the pipeline so the | |
| 355 | /// file's header and index are written. | |
| 356 | pub fn stop(self) -> Result<(), String> { | |
| 354 | /// Ends the recording, its file complete, with nothing left to run: the end travels | |
| 355 | /// down the pipeline so the file's header and index are written. | |
| 356 | pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> { | |
| 357 | 357 | let pipeline = &self.0; |
| 358 | 358 | pipeline.set(PLAYING); |
| 359 | 359 | unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) }; |
| 360 | 360 | match pipeline.wait(PATIENCE, EOS | ERROR) { |
| 361 | Some(EOS) => Ok(()), | |
| 361 | Some(EOS) => Ok(|| Ok(())), | |
| 362 | 362 | _ => Err("The recording stopped early. Try recording again.".into()), |
| 363 | 363 | } |
| 364 | 364 | } |
| ... | ... | @@ -397,7 +397,7 @@ mod tests { |
| 397 | 397 | recorder.pause(false); |
| 398 | 398 | } |
| 399 | 399 | std::thread::sleep(std::time::Duration::from_millis(seconds * 500)); |
| 400 | recorder.stop().unwrap(); | |
| 400 | recorder.stop().unwrap()().unwrap(); | |
| 401 | 401 | } |
| 402 | 402 | |
| 403 | 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 | 155 | pub fn open(path: &Path, audible: bool) -> Option<Self> { |
| 156 | 156 | let url = url(path)?; |
| 157 | 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 | 160 | let player: Option<Retained<AnyObject>> = msg_send_id![ |
| 160 | 161 | player, |
| 161 | 162 | initWithContentsOfURL: &*url, |
| ... | ... | @@ -218,6 +219,13 @@ pub enum Recorder { |
| 218 | 219 | /// Set when the capture session has finished writing its movie. |
| 219 | 220 | static MOVIE_WRITTEN: AtomicBool = AtomicBool::new(false); |
| 220 | 221 | |
| 222 | /// Mac OS X 10.6 has no AVFoundation to record with. | |
| 223 | fn 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 | 229 | /// What to do when the system refused `device` ("microphone" or "camera"). |
| 222 | 230 | fn refused(device: &str, pane: &str) -> String { |
| 223 | 231 | format!("Allow Snowbound to use the {device} in System Settings, Privacy & Security, {pane}.") |
| ... | ... | @@ -234,6 +242,7 @@ impl Recorder { |
| 234 | 242 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the |
| 235 | 243 | /// microphone the first time; refused, recording fails with what to do about it. |
| 236 | 244 | pub fn audio(path: &Path, rate: u32) -> Result<Self, String> { |
| 245 | recordable()?; | |
| 237 | 246 | unsafe { |
| 238 | 247 | if denied(AVMediaTypeAudio) { |
| 239 | 248 | return Err(refused("microphone", "Microphone")); |
| ... | ... | @@ -258,6 +267,7 @@ impl Recorder { |
| 258 | 267 | /// Records the default camera, with the microphone where there is one, as a Motion JPEG |
| 259 | 268 | /// AVI file at `path` once stopped. The system asks for each device the first time. |
| 260 | 269 | pub fn video(path: &Path) -> Result<Self, String> { |
| 270 | recordable()?; | |
| 261 | 271 | unsafe { |
| 262 | 272 | if denied(AVMediaTypeVideo) { |
| 263 | 273 | return Err(refused("camera", "Camera")); |
| ... | ... | @@ -346,12 +356,13 @@ impl Recorder { |
| 346 | 356 | } |
| 347 | 357 | } |
| 348 | 358 | |
| 349 | /// Ends the recording, its file complete. | |
| 350 | pub fn stop(self) -> Result<(), String> { | |
| 351 | match self { | |
| 359 | /// Ends the recording. Its file is complete once what this returns has run, on any | |
| 360 | /// thread: a video's conversion takes a while. | |
| 361 | pub fn stop(self) -> Result<impl FnOnce() -> Result<(), String> + Send, String> { | |
| 362 | let movie = match self { | |
| 352 | 363 | Self::Audio(recorder) => { |
| 353 | 364 | let _: () = unsafe { msg_send![&recorder, stop] }; |
| 354 | Ok(()) | |
| 365 | None | |
| 355 | 366 | } |
| 356 | 367 | Self::Video { |
| 357 | 368 | session, |
| ... | ... | @@ -373,11 +384,18 @@ impl Recorder { |
| 373 | 384 | } |
| 374 | 385 | let _: () = msg_send![&session, stopRunning]; |
| 375 | 386 | } |
| 376 | let avi = avi(&movie).ok_or("The recording stopped early. Try recording again."); | |
| 377 | let _ = std::fs::remove_file(&movie); | |
| 378 | std::fs::write(&path, avi?).map_err(|error| error.to_string()) | |
| 387 | Some((movie, path)) | |
| 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 | } |
| 383 | 401 |
crates/snowbound/src/menubar.rs+6| ... | ... | @@ -33,6 +33,7 @@ const MENUS: &[Item] = &[ |
| 33 | 33 | "Snowbound", |
| 34 | 34 | &[ |
| 35 | 35 | Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None), |
| 36 | C(Id::CheckForUpdates), | |
| 36 | 37 | S, |
| 37 | 38 | C(Id::Settings), |
| 38 | 39 | S, |
| ... | ... | @@ -54,6 +55,7 @@ const MENUS: &[Item] = &[ |
| 54 | 55 | &[ |
| 55 | 56 | C(Id::NewNotebook), |
| 56 | 57 | C(Id::OpenNotebook), |
| 58 | C(Id::OpenFromServer), | |
| 57 | 59 | C(Id::CloseNotebook), |
| 58 | 60 | S, |
| 59 | 61 | C(Id::NewSection), |
| ... | ... | @@ -94,6 +96,7 @@ const MENUS: &[Item] = &[ |
| 94 | 96 | Item::Menu( |
| 95 | 97 | "View", |
| 96 | 98 | &[ |
| 99 | C(Id::GoTo), | |
| 97 | 100 | C(Id::CommandPalette), |
| 98 | 101 | S, |
| 99 | 102 | C(Id::Back), |
| ... | ... | @@ -109,6 +112,8 @@ const MENUS: &[Item] = &[ |
| 109 | 112 | S, |
| 110 | 113 | C(Id::PageColor), |
| 111 | 114 | C(Id::DarkPages), |
| 115 | C(Id::HideSpelling), | |
| 116 | C(Id::Spelling), | |
| 112 | 117 | ], |
| 113 | 118 | ), |
| 114 | 119 | Item::Menu( |
| ... | ... | @@ -390,6 +395,7 @@ mod tests { |
| 390 | 395 | " " => "Space".to_owned(), |
| 391 | 396 | "\u{f702}" => "←".to_owned(), |
| 392 | 397 | "\u{f703}" => "→".to_owned(), |
| 398 | "\u{f70a}" => "F7".to_owned(), | |
| 393 | 399 | key => key.to_uppercase(), |
| 394 | 400 | }; |
| 395 | 401 | format!("{held}{key}") |
crates/snowbound/src/menus.rs+1-1| ... | ... | @@ -217,7 +217,7 @@ impl State { |
| 217 | 217 | (Target::Page(space), "Copy Link to Page") => { |
| 218 | 218 | let copied = self |
| 219 | 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 | 221 | if let Err(error) = copied { |
| 222 | 222 | eprintln!("Copying the link failed: {error}"); |
| 223 | 223 | } |
crates/snowbound/src/options.rs+67-7| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | //! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists |
| 2 | 2 | //! them, grouped under the same headings, each kept when OK is chosen. |
| 3 | 3 | |
| 4 | use crate::{State, platform, settings::ColorScheme}; | |
| 4 | use crate::{State, platform, settings::ColorScheme, update}; | |
| 5 | use accesskit::Role; | |
| 5 | 6 | use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px}; |
| 6 | 7 | use winit::keyboard::NamedKey; |
| 7 | 8 | |
| ... | ... | @@ -38,6 +39,7 @@ pub struct Options { |
| 38 | 39 | user_name: String, |
| 39 | 40 | color_scheme: ColorScheme, |
| 40 | 41 | light_pages: bool, |
| 42 | automatic_updates: bool, | |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | 45 | fn id() -> Id { |
| ... | ... | @@ -59,6 +61,7 @@ impl State { |
| 59 | 61 | user_name: self.author.clone(), |
| 60 | 62 | color_scheme: self.color_scheme, |
| 61 | 63 | light_pages: self.light_pages, |
| 64 | automatic_updates: self.updates.automatic(), | |
| 62 | 65 | }); |
| 63 | 66 | self.ui.open_popup(id()); |
| 64 | 67 | self.ui.set_focus(Some(user_name())); |
| ... | ... | @@ -90,9 +93,13 @@ impl State { |
| 90 | 93 | radius: 8.0, |
| 91 | 94 | pad: [6.0, 6.0], |
| 92 | 95 | anchor: Some(Anchor::Dialog), |
| 96 | role: Some(Role::Dialog), | |
| 93 | 97 | ..Spec::default() |
| 94 | 98 | }, |
| 95 | 99 | ); |
| 100 | if let Some(node) = ui.access(id()) { | |
| 101 | node.set_label("Options"); | |
| 102 | } | |
| 96 | 103 | ui.open( |
| 97 | 104 | "body", |
| 98 | 105 | Spec { |
| ... | ... | @@ -100,7 +107,7 @@ impl State { |
| 100 | 107 | ..Spec::default() |
| 101 | 108 | }, |
| 102 | 109 | ); |
| 103 | ui.open( | |
| 110 | let pages = ui.open( | |
| 104 | 111 | "pages", |
| 105 | 112 | Spec { |
| 106 | 113 | axis: Axis::Y, |
| ... | ... | @@ -109,9 +116,13 @@ impl State { |
| 109 | 116 | radius: 5.0, |
| 110 | 117 | pad: [6.0, 6.0], |
| 111 | 118 | gap: 2.0, |
| 119 | role: Some(Role::TabList), | |
| 112 | 120 | ..Spec::default() |
| 113 | 121 | }, |
| 114 | 122 | ); |
| 123 | if let Some(node) = ui.access(pages) { | |
| 124 | node.set_orientation(accesskit::Orientation::Vertical); | |
| 125 | } | |
| 115 | 126 | for (page, name) in Page::ALL { |
| 116 | 127 | let shown = page == options.page; |
| 117 | 128 | let spec = Spec { |
| ... | ... | @@ -123,9 +134,15 @@ impl State { |
| 123 | 134 | hover_fill: Some(theme.hover()), |
| 124 | 135 | radius: 4.0, |
| 125 | 136 | pad: [8.0, 0.0], |
| 137 | role: Some(Role::Tab), | |
| 126 | 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 | 146 | options.page = page; |
| 130 | 147 | } |
| 131 | 148 | } |
| ... | ... | @@ -149,12 +166,11 @@ impl State { |
| 149 | 166 | .iter() |
| 150 | 167 | .find(|(scheme, _)| *scheme == options.color_scheme) |
| 151 | 168 | .map_or("", |(_, name)| name); |
| 152 | if ui::shell::combo(ui, "combo", current, 140.0).pressed { | |
| 153 | ui.open_popup(schemes()); | |
| 154 | } | |
| 169 | ui::shell::combo(ui, "combo", "Color scheme", current, 140.0, schemes(), true); | |
| 155 | 170 | let items = SCHEMES.map(|(scheme, name)| Item { |
| 156 | 171 | text: name, |
| 157 | checked: scheme == options.color_scheme, | |
| 172 | checked: Some(scheme == options.color_scheme), | |
| 173 | current: scheme == options.color_scheme, | |
| 158 | 174 | ..Item::default() |
| 159 | 175 | }); |
| 160 | 176 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); |
| ... | ... | @@ -178,7 +194,49 @@ impl State { |
| 178 | 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 | 241 | Page::Display => { |
| 184 | 242 | heading(ui, &theme, "Display"); |
| ... | ... | @@ -246,6 +304,7 @@ impl State { |
| 246 | 304 | self.author = name.to_owned(); |
| 247 | 305 | self.color_scheme = options.color_scheme; |
| 248 | 306 | self.light_pages = options.light_pages; |
| 307 | self.updates.set_automatic(options.automatic_updates); | |
| 249 | 308 | self.follow_color_scheme(); |
| 250 | 309 | self.save_settings(); |
| 251 | 310 | } else if !cancel { |
| ... | ... | @@ -267,6 +326,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) { |
| 267 | 326 | fill: Some(ui::mix(theme.panel, theme.chip, 0.4)), |
| 268 | 327 | radius: 4.0, |
| 269 | 328 | pad: [8.0, 0.0], |
| 329 | role: Some(Role::Heading), | |
| 270 | 330 | ..Spec::default() |
| 271 | 331 | }, |
| 272 | 332 | ); |
crates/snowbound/src/palette.rs+72-31| ... | ... | @@ -1,33 +1,65 @@ |
| 1 | //! The command palette: the command table's commands, then the open notebooks' sections and | |
| 2 | //! every page the search index holds to go to, narrowed as typed. | |
| 1 | //! The palette: the open notebooks, their sections and every page the search index holds to | |
| 2 | //! go to, or after a leading `>` the command table's commands, narrowed as typed. | |
| 3 | 3 | |
| 4 | 4 | use crate::{ |
| 5 | Command, Library, State, | |
| 6 | commands::{self, COMMANDS, Choice}, | |
| 5 | Command, Library, State, art, | |
| 6 | commands::{self, Choice}, | |
| 7 | 7 | library, |
| 8 | 8 | }; |
| 9 | 9 | use onestore::ExGuid; |
| 10 | 10 | use std::sync::Arc; |
| 11 | 11 | use ui::{Id, popup::Item}; |
| 12 | 12 | |
| 13 | /// What the palette's query starts with to list commands. | |
| 14 | pub(crate) const COMMANDS: &str = ">"; | |
| 15 | ||
| 13 | 16 | pub(crate) fn id() -> Id { |
| 14 | 17 | Id::ROOT.child("palette") |
| 15 | 18 | } |
| 16 | 19 | |
| 17 | 20 | enum Target { |
| 18 | 21 | Command(commands::Id), |
| 22 | Notebook(String), | |
| 19 | 23 | Section(Arc<Library>, String), |
| 20 | 24 | Page(String, ExGuid), |
| 21 | 25 | } |
| 22 | 26 | |
| 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. | |
| 24 | 28 | struct Row { |
| 25 | 29 | text: String, |
| 26 | 30 | after: String, |
| 31 | icon: Option<&'static [&'static str]>, | |
| 32 | tint: Option<[f32; 4]>, | |
| 27 | 33 | disabled: bool, |
| 28 | 34 | target: Option<Target>, |
| 29 | 35 | } |
| 30 | 36 | |
| 37 | impl 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 | ||
| 31 | 63 | impl State { |
| 32 | 64 | /// Builds the palette while it is open, and runs what is chosen from it. |
| 33 | 65 | pub(crate) fn palette(&mut self) { |
| ... | ... | @@ -35,31 +67,44 @@ impl State { |
| 35 | 67 | return; |
| 36 | 68 | } |
| 37 | 69 | let format = self.format_state(); |
| 38 | let mut rows: Vec<Row> = COMMANDS | |
| 70 | let mut commands: Vec<Row> = commands::COMMANDS | |
| 39 | 71 | .iter() |
| 72 | .filter(|command| commands::offered(command.id)) | |
| 40 | 73 | .map(|command| Row { |
| 41 | 74 | text: command.title.to_owned(), |
| 42 | 75 | after: commands::shortcut(command.id), |
| 76 | icon: crate::artwork(command.id), | |
| 77 | tint: None, | |
| 43 | 78 | disabled: !self.status(&Choice::Command(command.id), &format).enabled, |
| 44 | 79 | target: Some(Target::Command(command.id)), |
| 45 | 80 | }) |
| 46 | 81 | .collect(); |
| 47 | rows.extend(self.tags.iter().enumerate().map(|(place, tag)| { | |
| 82 | commands.extend(self.tags.iter().enumerate().map(|(place, tag)| { | |
| 48 | 83 | let id = commands::Id::Tag(place); |
| 49 | 84 | Row { |
| 50 | 85 | text: tag.label.clone(), |
| 51 | 86 | after: commands::shortcut(id), |
| 87 | icon: Some(crate::tag_art(tag)), | |
| 88 | tint: Some([1.0; 4]), | |
| 52 | 89 | disabled: !self.status(&Choice::Command(id), &format).enabled, |
| 53 | 90 | target: Some(Target::Command(id)), |
| 54 | 91 | } |
| 55 | 92 | })); |
| 56 | let heading = |text: &str| Row { | |
| 57 | text: text.to_owned(), | |
| 58 | after: String::new(), | |
| 59 | disabled: false, | |
| 60 | target: None, | |
| 61 | }; | |
| 62 | rows.push(heading("Sections")); | |
| 93 | let mut places = vec![Row::heading("Notebooks")]; | |
| 94 | places.extend( | |
| 95 | (self.notebooks.iter()) | |
| 96 | .filter(|library| library.catalog().is_some()) | |
| 97 | .map(|library| Row { | |
| 98 | text: library.name.clone(), | |
| 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 | 108 | for library in &self.notebooks { |
| 64 | 109 | // A section opened on its own has no catalog, and lists itself at "". |
| 65 | 110 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); |
| ... | ... | @@ -80,21 +125,23 @@ impl State { |
| 80 | 125 | paths.collect() |
| 81 | 126 | }; |
| 82 | 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 | 129 | text: tab.name, |
| 85 | 130 | after: library.name.clone(), |
| 131 | icon: Some(art::SECTION), | |
| 132 | tint: Some(theme.section(crate::section_color(tab.color)).accent), | |
| 86 | 133 | disabled: false, |
| 87 | 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 | 139 | let index = self |
| 93 | 140 | .search |
| 94 | 141 | .index |
| 95 | 142 | .lock() |
| 96 | 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 | 145 | let (location, path) = entry.section.split_once('\n').unwrap_or_default(); |
| 99 | 146 | let mut section = library::section_name(path, &None); |
| 100 | 147 | if section.is_empty() { |
| ... | ... | @@ -109,30 +156,24 @@ impl State { |
| 109 | 156 | entry.title.clone() |
| 110 | 157 | }, |
| 111 | 158 | after: section, |
| 159 | icon: Some(art::PAGE), | |
| 160 | tint: None, | |
| 112 | 161 | disabled: false, |
| 113 | 162 | target: Some(Target::Page(entry.section.clone(), entry.space)), |
| 114 | 163 | } |
| 115 | 164 | })); |
| 116 | 165 | drop(index); |
| 117 | let items: Vec<Item> = rows | |
| 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(); | |
| 166 | let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); | |
| 128 | 167 | let chosen = ui::popup::palette( |
| 129 | 168 | &mut self.ui, |
| 130 | 169 | id(), |
| 131 | &items, | |
| 132 | "Search commands, sections and pages", | |
| 170 | &[(COMMANDS, &items[0]), ("", &items[1])], | |
| 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 | 175 | Some(Target::Command(command)) => self.choose(Choice::Command(command)), |
| 176 | Some(Target::Notebook(location)) => self.open_notebook(location, None), | |
| 136 | 177 | Some(Target::Section(library, path)) => { |
| 137 | 178 | self.commands.push(Command::OpenSection(library, path)); |
| 138 | 179 | } |
crates/snowbound/src/pane.rs+204-16| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | //! 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. | |
| 3 | 4 | |
| 4 | 5 | use crate::search::{Scope, marked}; |
| 5 | use crate::{State, Theme, art, page}; | |
| 6 | use crate::{State, Theme, art, page, platform}; | |
| 6 | 7 | use canvas::gpu::tag_sources; |
| 7 | 8 | use canvas::outline::TagIcon; |
| 8 | 9 | use canvas::search::{Found, Tagged}; |
| ... | ... | @@ -30,6 +31,10 @@ pub enum Pane { |
| 30 | 31 | unchecked: bool, |
| 31 | 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 | } |
| 34 | 39 | |
| 35 | 40 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | ... | @@ -330,18 +335,25 @@ fn popup(name: &str) -> Id { |
| 330 | 335 | Id::ROOT.child(("pane popup", name)) |
| 331 | 336 | } |
| 332 | 337 | |
| 333 | /// A combo box `width` wide showing `current` of `names`, returning the one chosen. | |
| 334 | fn choice(ui: &mut Ui, name: &str, current: usize, names: &[&str], width: f32) -> Option<usize> { | |
| 338 | /// A combo box `label`led, `width` wide, showing `current` of `names`, returning the one | |
| 339 | /// chosen. | |
| 340 | fn choice( | |
| 341 | ui: &mut Ui, | |
| 342 | name: &str, | |
| 343 | label: &str, | |
| 344 | current: usize, | |
| 345 | names: &[&str], | |
| 346 | width: f32, | |
| 347 | ) -> Option<usize> { | |
| 335 | 348 | let combo = ui.id(name); |
| 336 | if ui::shell::combo(ui, name, names[current], width).pressed { | |
| 337 | ui.open_popup(popup(name)); | |
| 338 | } | |
| 349 | ui::shell::combo(ui, name, label, names[current], width, popup(name), true); | |
| 339 | 350 | let items: Vec<_> = names |
| 340 | 351 | .iter() |
| 341 | 352 | .enumerate() |
| 342 | 353 | .map(|(index, text)| ui::popup::Item { |
| 343 | 354 | text, |
| 344 | checked: index == current, | |
| 355 | checked: Some(index == current), | |
| 356 | current: index == current, | |
| 345 | 357 | ..Default::default() |
| 346 | 358 | }) |
| 347 | 359 | .collect(); |
| ... | ... | @@ -414,11 +426,151 @@ impl State { |
| 414 | 426 | |
| 415 | 427 | /// Closes the pane; the Search Results pane takes its search, and the marks, with it. |
| 416 | 428 | fn close_pane(&mut self) { |
| 429 | self.correction = None; | |
| 417 | 430 | if matches!(self.search.pane.take(), Some(Pane::Search { .. })) { |
| 418 | 431 | self.end_search(); |
| 419 | 432 | } |
| 420 | 433 | } |
| 421 | 434 | |
| 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 | 574 | /// The open pane at the window's right, easing open and closed. |
| 423 | 575 | pub(crate) fn task_pane(&mut self, theme: &Theme) { |
| 424 | 576 | let id = self.ui.id("task pane"); |
| ... | ... | @@ -465,16 +617,19 @@ impl State { |
| 465 | 617 | text: Some(match pane { |
| 466 | 618 | Pane::Search { .. } => "Search Results", |
| 467 | 619 | Pane::Tags { .. } => "Tags Summary", |
| 620 | Pane::Spelling { .. } => "Spelling", | |
| 468 | 621 | }), |
| 469 | 622 | bold: true, |
| 470 | 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 | 627 | .clicked |
| 475 | 628 | { |
| 476 | 629 | self.close_pane(); |
| 477 | 630 | } |
| 631 | let close = self.ui.id("close"); | |
| 632 | crate::name(&mut self.ui, close, "Close"); | |
| 478 | 633 | self.ui.close(); |
| 479 | 634 | match self.search.pane { |
| 480 | 635 | Some(Pane::Search { sort, descending }) => { |
| ... | ... | @@ -487,6 +642,7 @@ impl State { |
| 487 | 642 | unchecked, |
| 488 | 643 | scope, |
| 489 | 644 | }) => self.tags_pane(theme, group, unchecked, scope), |
| 645 | Some(Pane::Spelling { chosen }) => self.spelling_pane(theme, chosen), | |
| 490 | 646 | None => {} |
| 491 | 647 | } |
| 492 | 648 | } |
| ... | ... | @@ -532,9 +688,11 @@ impl State { |
| 532 | 688 | border: Some(border), |
| 533 | 689 | radius: 4.0, |
| 534 | 690 | pad: [6.0, 0.0], |
| 691 | role: Some(accesskit::Role::SearchInput), | |
| 535 | 692 | ..Spec::default() |
| 536 | 693 | }, |
| 537 | 694 | ); |
| 695 | crate::name(&mut self.ui, field(), "Search"); | |
| 538 | 696 | if std::mem::take(&mut self.search.claim) { |
| 539 | 697 | self.ui.set_focus(Some(field())); |
| 540 | 698 | } |
| ... | ... | @@ -544,7 +702,14 @@ impl State { |
| 544 | 702 | .iter() |
| 545 | 703 | .position(|scope| *scope == self.search.scope) |
| 546 | 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 | 713 | self.search.scope = Scope::ALL[index]; |
| 549 | 714 | } |
| 550 | 715 | self.ui.open( |
| ... | ... | @@ -561,7 +726,7 @@ impl State { |
| 561 | 726 | .position(|listed| *listed == sort) |
| 562 | 727 | .unwrap_or_default(); |
| 563 | 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 | 730 | sort = Sort::ALL[index]; |
| 566 | 731 | // Dates start newest first, names from the start of the alphabet. |
| 567 | 732 | descending = sort == Sort::Date; |
| ... | ... | @@ -571,7 +736,7 @@ impl State { |
| 571 | 736 | } else { |
| 572 | 737 | 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 | 740 | descending = !descending; |
| 576 | 741 | } |
| 577 | 742 | let tip = if descending { |
| ... | ... | @@ -603,14 +768,16 @@ impl State { |
| 603 | 768 | }) |
| 604 | 769 | .collect(); |
| 605 | 770 | let before = self.search.selected; |
| 771 | let list = Id::ROOT.child("pane results"); | |
| 606 | 772 | let clicked = ui::list( |
| 607 | 773 | &mut self.ui, |
| 608 | Id::ROOT.child("pane results"), | |
| 774 | list, | |
| 609 | 775 | Spec { |
| 610 | 776 | size: [fill(), fill()], |
| 611 | 777 | fill: Some(theme.base), |
| 612 | 778 | border: Some(theme.chip), |
| 613 | 779 | radius: 4.0, |
| 780 | role: Some(accesskit::Role::ListBox), | |
| 614 | 781 | ..Spec::default() |
| 615 | 782 | }, |
| 616 | 783 | ui::List { |
| ... | ... | @@ -621,6 +788,10 @@ impl State { |
| 621 | 788 | }, |
| 622 | 789 | &mut self.search.selected, |
| 623 | 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 | 795 | if let Ok(group) = rows.starts.binary_search(&row.index) { |
| 625 | 796 | heading(ui, theme, &headings[group]); |
| 626 | 797 | } |
| ... | ... | @@ -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 | 812 | let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter); |
| 637 | 813 | let chosen = clicked.or_else(|| { |
| 638 | 814 | self.search |
| ... | ... | @@ -670,7 +846,14 @@ impl State { |
| 670 | 846 | .iter() |
| 671 | 847 | .position(|listed| *listed == group) |
| 672 | 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 | 857 | group = Group::ALL[index]; |
| 675 | 858 | } |
| 676 | 859 | if ui::check_box( |
| ... | ... | @@ -686,14 +869,16 @@ impl State { |
| 686 | 869 | let tagged = self.tagged(scope, unchecked); |
| 687 | 870 | let (order, rows, headings) = arrange_tags(&tagged, group); |
| 688 | 871 | let mut selected = None; |
| 872 | let list = Id::ROOT.child("pane tags"); | |
| 689 | 873 | let clicked = ui::list( |
| 690 | 874 | &mut self.ui, |
| 691 | Id::ROOT.child("pane tags"), | |
| 875 | list, | |
| 692 | 876 | Spec { |
| 693 | 877 | size: [fill(), fill()], |
| 694 | 878 | fill: Some(theme.base), |
| 695 | 879 | border: Some(theme.chip), |
| 696 | 880 | radius: 4.0, |
| 881 | role: Some(accesskit::Role::List), | |
| 697 | 882 | ..Spec::default() |
| 698 | 883 | }, |
| 699 | 884 | ui::List { |
| ... | ... | @@ -704,6 +889,9 @@ impl State { |
| 704 | 889 | }, |
| 705 | 890 | &mut selected, |
| 706 | 891 | |ui, row| { |
| 892 | if let Some(node) = ui.access(list.child(row.key)) { | |
| 893 | node.set_role(accesskit::Role::ListItem); | |
| 894 | } | |
| 707 | 895 | if let Ok(at) = rows.starts.binary_search(&row.index) { |
| 708 | 896 | heading(ui, theme, &headings[at]); |
| 709 | 897 | } |
| ... | ... | @@ -716,7 +904,7 @@ impl State { |
| 716 | 904 | .iter() |
| 717 | 905 | .position(|listed| *listed == scope) |
| 718 | 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 | 908 | scope = TagScope::ALL[index]; |
| 721 | 909 | } |
| 722 | 910 | self.search.pane = Some(Pane::Tags { |
crates/snowbound/src/recording.rs+125-40| ... | ... | @@ -22,6 +22,12 @@ pub(crate) enum Media { |
| 22 | 22 | video: bool, |
| 23 | 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 | 31 | Playing(Box<Playback>), |
| 26 | 32 | } |
| 27 | 33 | |
| ... | ... | @@ -61,10 +67,11 @@ impl State { |
| 61 | 67 | /// Record Audio or Record Video: starts recording at the caret, or stops the recording |
| 62 | 68 | /// under way. |
| 63 | 69 | pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> { |
| 64 | if matches!(self.media, Media::Recording { .. }) { | |
| 65 | return self.stop_recording(); | |
| 70 | match self.media { | |
| 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 | 75 | let folder = std::env::temp_dir().join("Snowbound Recordings"); |
| 69 | 76 | std::fs::create_dir_all(&folder)?; |
| 70 | 77 | let extension = if video { "avi" } else { "wav" }; |
| ... | ... | @@ -92,7 +99,7 @@ impl State { |
| 92 | 99 | let (id, response) = self.view.start_recording(&label)?; |
| 93 | 100 | self.respond(response); |
| 94 | 101 | if id.is_none() { |
| 95 | recorder.stop()?; | |
| 102 | recorder.stop()?()?; | |
| 96 | 103 | return Ok(()); |
| 97 | 104 | } |
| 98 | 105 | self.media = Media::Recording { |
| ... | ... | @@ -103,33 +110,63 @@ impl State { |
| 103 | 110 | Ok(()) |
| 104 | 111 | } |
| 105 | 112 | |
| 106 | /// Stop: the recording under way goes on the page where it started. | |
| 107 | pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> { | |
| 108 | let Media::Recording { | |
| 109 | recorder, | |
| 110 | video, | |
| 111 | path, | |
| 112 | } = std::mem::take(&mut self.media) | |
| 113 | else { | |
| 114 | return Ok(()); | |
| 113 | /// Stop: the recording under way stops, and goes on the page where it started once its | |
| 114 | /// file is saved, which happens meanwhile; `wait` waits for it. | |
| 115 | pub(crate) fn stop_recording(&mut self, wait: bool) -> Result<(), Box<dyn Error>> { | |
| 116 | match std::mem::take(&mut self.media) { | |
| 117 | Media::Recording { | |
| 118 | recorder, | |
| 119 | video, | |
| 120 | path, | |
| 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 | 155 | let Some(id) = self.view.editor.recording() else { |
| 117 | 156 | return Ok(()); |
| 118 | 157 | }; |
| 119 | if let Err(detail) = recorder.stop() { | |
| 120 | platform::alert("Couldn't finish the recording", &detail); | |
| 121 | return Ok(()); | |
| 122 | } | |
| 123 | let recorded = std::fs::read(&path)?; | |
| 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), | |
| 158 | let bytes = match saved { | |
| 159 | Ok(bytes) => bytes, | |
| 160 | Err(detail) => { | |
| 161 | platform::alert("Couldn't finish the recording", &detail); | |
| 162 | return Ok(()); | |
| 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 | 170 | let preview = platform::file_icon(&path); |
| 134 | 171 | std::fs::remove_file(&path)?; |
| 135 | 172 | let title = self.view.editor.page()?.title; |
| ... | ... | @@ -176,7 +213,7 @@ impl State { |
| 176 | 213 | /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's |
| 177 | 214 | /// player where Snowbound cannot play it. |
| 178 | 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 | 217 | return Ok(()); |
| 181 | 218 | } |
| 182 | 219 | self.media = Media::Idle; |
| ... | ... | @@ -233,18 +270,18 @@ impl State { |
| 233 | 270 | |
| 234 | 271 | pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status { |
| 235 | 272 | let (recording, playing) = match &self.media { |
| 236 | Media::Idle => (false, None), | |
| 273 | Media::Idle | Media::Saving { .. } => (false, None), | |
| 237 | 274 | Media::Recording { .. } => (true, None), |
| 238 | 275 | Media::Playing(playback) => (false, Some(playback.player.playing())), |
| 239 | 276 | }; |
| 240 | 277 | let (enabled, checked) = match transport { |
| 241 | 278 | Transport::Pause => ( |
| 242 | 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), | |
| 246 | Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), false), | |
| 247 | Transport::SeePlayback => (true, self.see_playback), | |
| 282 | Transport::Stop => (recording || playing.is_some(), None), | |
| 283 | Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), None), | |
| 284 | Transport::SeePlayback => (true, Some(self.see_playback)), | |
| 248 | 285 | }; |
| 249 | 286 | crate::commands::Status { enabled, checked } |
| 250 | 287 | } |
| ... | ... | @@ -253,8 +290,8 @@ impl State { |
| 253 | 290 | pub(crate) fn run_transport(&mut self, transport: Transport) -> Result<(), Box<dyn Error>> { |
| 254 | 291 | match (transport, &mut self.media) { |
| 255 | 292 | (Transport::SeePlayback, _) => self.see_playback = !self.see_playback, |
| 256 | (Transport::Stop, Media::Recording { .. }) => self.stop_recording()?, | |
| 257 | (Transport::Stop, _) => self.media = Media::Idle, | |
| 293 | (Transport::Stop, Media::Recording { .. }) => self.stop_recording(false)?, | |
| 294 | (Transport::Stop, Media::Playing(_)) => self.media = Media::Idle, | |
| 258 | 295 | (Transport::Pause, Media::Recording { recorder, .. }) => { |
| 259 | 296 | let paused = !self.view.editor.recording_paused(); |
| 260 | 297 | recorder.pause(paused); |
| ... | ... | @@ -294,9 +331,14 @@ impl State { |
| 294 | 331 | page: [f32; 4], |
| 295 | 332 | ) -> Result<(), Box<dyn Error>> { |
| 296 | 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 | 338 | let (clock_text, picture) = match &mut self.media { |
| 298 | 339 | Media::Idle => { |
| 299 | self.view.played = None; | |
| 340 | let response = self.view.set_played(None)?; | |
| 341 | self.respond(response); | |
| 300 | 342 | return Ok(()); |
| 301 | 343 | } |
| 302 | 344 | Media::Recording { video, .. } => { |
| ... | ... | @@ -311,9 +353,18 @@ impl State { |
| 311 | 353 | self.ui.wake_after(Duration::from_millis(250)); |
| 312 | 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 | 365 | Media::Playing(playback) => { |
| 315 | 366 | let at = playback.player.position_ms(); |
| 316 | self.view.played = playback | |
| 367 | played = playback | |
| 317 | 368 | .recording |
| 318 | 369 | .filter(|_| self.see_playback) |
| 319 | 370 | .and_then(|id| self.view.editor.played_note(id, at)) |
| ... | ... | @@ -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 | 405 | let picture_height = picture |
| 353 | 406 | .as_ref() |
| 354 | 407 | .map_or(0.0, |image| image.size()[1] as f32 + 6.0); |
| ... | ... | @@ -388,14 +441,19 @@ impl State { |
| 388 | 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 | 445 | let playing = matches!(self.media, Media::Playing(_)); |
| 393 | 446 | let pause = match (playing, paused) { |
| 394 | 447 | (true, true) => "Play", |
| 395 | 448 | (false, true) => "Resume", |
| 396 | 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 | 457 | if playing { |
| 400 | 458 | buttons.extend([ |
| 401 | 459 | ("−10 min", Transport::Skip(-600)), |
| ... | ... | @@ -404,7 +462,7 @@ impl State { |
| 404 | 462 | } |
| 405 | 463 | let mut chosen = buttons |
| 406 | 464 | .into_iter() |
| 407 | .filter(|(label, _)| ui::button(&mut self.ui, label, label).clicked) | |
| 465 | .filter(|(label, transport)| self.transport_button(label, *transport)) | |
| 408 | 466 | .last() |
| 409 | 467 | .map(|(_, transport)| transport); |
| 410 | 468 | let seek = self.seek(theme, &clock_text); |
| ... | ... | @@ -416,7 +474,7 @@ impl State { |
| 416 | 474 | ]); |
| 417 | 475 | } |
| 418 | 476 | for (label, transport) in after { |
| 419 | if ui::button(&mut self.ui, label, label).clicked { | |
| 477 | if self.transport_button(label, transport) { | |
| 420 | 478 | chosen = Some(transport); |
| 421 | 479 | } |
| 422 | 480 | } |
| ... | ... | @@ -424,7 +482,7 @@ impl State { |
| 424 | 482 | { |
| 425 | 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 | 486 | chosen = Some(Transport::Stop); |
| 429 | 487 | } |
| 430 | 488 | self.ui.close(); |
| ... | ... | @@ -440,6 +498,20 @@ impl State { |
| 440 | 498 | Ok(()) |
| 441 | 499 | } |
| 442 | 500 | |
| 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 | 515 | /// The transport's clock, or while seeking the field taking the moment: Enter goes |
| 444 | 516 | /// there, Escape or leaving the field keeps playing where it was. A click on the clock |
| 445 | 517 | /// seeks as Seek To does. |
| ... | ... | @@ -466,10 +538,18 @@ impl State { |
| 466 | 538 | text: Some(clock_text), |
| 467 | 539 | pad: [6.0, 0.0], |
| 468 | 540 | center: true, |
| 541 | role: playing.then_some(accesskit::Role::Button), | |
| 469 | 542 | ..Spec::default() |
| 470 | 543 | }, |
| 471 | 544 | ) |
| 472 | 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 | 553 | if clicked { |
| 474 | 554 | let _ = self.run_transport(Transport::SeekTo); |
| 475 | 555 | } |
| ... | ... | @@ -1045,5 +1125,10 @@ pub(crate) mod tests { |
| 1045 | 1125 | assert_eq!(clock(8_400), "0:08"); |
| 1046 | 1126 | assert_eq!(clock(754_000), "12:34"); |
| 1047 | 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 | 65 | ..Spec::default() |
| 66 | 66 | }, |
| 67 | 67 | ); |
| 68 | crate::name(ui, field(), "Name"); | |
| 68 | 69 | signal.events.iter().find_map(|event| match event { |
| 69 | 70 | Event::Key { |
| 70 | 71 | key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)), |
crates/snowbound/src/screenshot.rs+57-18| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | //! `--screenshot`: the window drawn offscreen at a fixed size in each appearance. On macOS |
| 2 | 2 | //! 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")] | |
| 7 | mod snow_leopard; | |
| 4 | 8 | |
| 5 | 9 | use super::*; |
| 6 | 10 | use winit::window::Theme as Appearance; |
| ... | ... | @@ -9,6 +13,8 @@ use winit::window::Theme as Appearance; |
| 9 | 13 | /// drawn at `SCALE` pixels per point or fewer where the GPU caps textures. |
| 10 | 14 | const SIZE: [f32; 2] = [1440.0, 900.0]; |
| 11 | 15 | const SCALE: f32 = 2.0; |
| 16 | /// The window's content as a Snow Leopard window, its default size. | |
| 17 | const SNOW_LEOPARD_SIZE: [f32; 2] = [1180.0, 760.0]; | |
| 12 | 18 | /// The close, minimize and zoom buttons' fill and rim. |
| 13 | 19 | #[cfg(target_os = "macos")] |
| 14 | 20 | const LIGHTS: [([u8; 3], [u8; 3]); 3] = [ |
| ... | ... | @@ -17,18 +23,40 @@ const LIGHTS: [([u8; 3], [u8; 3]); 3] = [ |
| 17 | 23 | ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), |
| 18 | 24 | ]; |
| 19 | 25 | |
| 26 | /// Whether `SNOWBOUND_SCREENSHOT_SYSTEM` asks for a Snow Leopard window. | |
| 27 | fn 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. | |
| 34 | pub(crate) fn prepare() { | |
| 35 | #[cfg(target_os = "macos")] | |
| 36 | if snow_leopard() { | |
| 37 | crate::aqua::pretend(); | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 20 | 41 | impl 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 | 45 | pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> { |
| 23 | 46 | let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") { |
| 24 | 47 | Ok(size) => size |
| 25 | 48 | .split_once('x') |
| 26 | 49 | .and_then(|(width, height)| Some([width.parse().ok()?, height.parse().ok()?])) |
| 27 | 50 | .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?, |
| 51 | Err(_) if snow_leopard() => SNOW_LEOPARD_SIZE, | |
| 28 | 52 | Err(_) => SIZE, |
| 29 | 53 | }; |
| 30 | 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 | 60 | self.surface.size = size.map(|side| (side * scale).max(1.0) as u32); |
| 33 | 61 | self.renderer.clear_glyph_cache(); |
| 34 | 62 | let response = self.view.scale_factor_changed(scale)?; |
| ... | ... | @@ -43,24 +71,14 @@ impl State { |
| 43 | 71 | { |
| 44 | 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 | 78 | for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { |
| 47 | 79 | self.window.set_theme(Some(appearance)); |
| 48 | 80 | self.set_appearance(appearance); |
| 49 | // The page's box sizes the view whose pictures settle. | |
| 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 | } | |
| 81 | self.settle_frame(size, scale)?; | |
| 64 | 82 | let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); |
| 65 | 83 | self.snapshot(&path)?; |
| 66 | 84 | #[cfg(target_os = "macos")] |
| ... | ... | @@ -75,6 +93,27 @@ impl State { |
| 75 | 93 | } |
| 76 | 94 | } |
| 77 | 95 | |
| 96 | impl 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 | 117 | /// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners |
| 79 | 118 | /// to transparency and edges them with the window's hairline. |
| 80 | 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 | ||
| 6 | use super::*; | |
| 7 | ||
| 8 | /// The title line's height. | |
| 9 | const 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. | |
| 12 | const 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. | |
| 15 | const 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. | |
| 20 | const 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. | |
| 24 | const SHADOW: [([usize; 3], usize, usize, f32); 2] = | |
| 25 | [([40, 25, 55], 14, 15, 0.5), ([20, 10, 30], 7, 10, 0.4)]; | |
| 26 | ||
| 27 | pub(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. | |
| 54 | fn 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. | |
| 112 | fn 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. | |
| 134 | fn 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 | ||
| 142 | fn 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. | |
| 152 | fn 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`. | |
| 165 | fn 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. | |
| 174 | fn 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. | |
| 233 | fn 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 | ||
| 274 | fn 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 | 3 | //! its own, from the section files and the open section's replica. |
| 4 | 4 | |
| 5 | 5 | use crate::pane::Pane; |
| 6 | use crate::{Command, Library, State, Theme, art, commands, lap, page}; | |
| 6 | use crate::{Command, Library, State, Theme, art, commands, lap, name, page}; | |
| 7 | use accesskit::Role; | |
| 7 | 8 | use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match}; |
| 8 | 9 | use notebook::Replica; |
| 9 | 10 | use onestore::ExGuid; |
| ... | ... | @@ -735,9 +736,11 @@ impl State { |
| 735 | 736 | Spec { |
| 736 | 737 | size: [fill(), px(ui::shell::TOOL)], |
| 737 | 738 | pad: [4.0, 0.0], |
| 739 | role: Some(Role::SearchInput), | |
| 738 | 740 | ..Spec::default() |
| 739 | 741 | }, |
| 740 | 742 | ); |
| 743 | name(&mut self.ui, field(), "Search"); | |
| 741 | 744 | if signal.pressed && !searching { |
| 742 | 745 | self.start_search(); |
| 743 | 746 | } |
| ... | ... | @@ -755,12 +758,18 @@ impl State { |
| 755 | 758 | |
| 756 | 759 | fn scope_button(&mut self, theme: &Theme) { |
| 757 | 760 | let button = self.ui.id("scope"); |
| 758 | if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed | |
| 761 | if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, None).pressed | |
| 759 | 762 | { |
| 760 | 763 | let rect = self.ui.rect(button).unwrap_or_default(); |
| 761 | 764 | self.search.anchor = Some(Anchor::Below(rect)); |
| 762 | 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 | } |
| 765 | 774 | |
| 766 | 775 | /// OneNote's "Search In:" menu. |
| ... | ... | @@ -785,7 +794,7 @@ impl State { |
| 785 | 794 | .enumerate() |
| 786 | 795 | .map(|(index, scope)| ui::popup::Item { |
| 787 | 796 | text: scope.name(), |
| 788 | checked: *scope == current, | |
| 797 | checked: Some(*scope == current), | |
| 789 | 798 | separated: index == 0, |
| 790 | 799 | ..Default::default() |
| 791 | 800 | }), |
| ... | ... | @@ -854,9 +863,11 @@ impl State { |
| 854 | 863 | pad: [4.0, 4.0], |
| 855 | 864 | gap: 4.0, |
| 856 | 865 | anchor: Some(anchor), |
| 866 | role: Some(Role::Dialog), | |
| 857 | 867 | ..Spec::default() |
| 858 | 868 | }, |
| 859 | 869 | ); |
| 870 | name(&mut self.ui, results(), "Search"); | |
| 860 | 871 | self.ui.open( |
| 861 | 872 | "box", |
| 862 | 873 | Spec { |
| ... | ... | @@ -884,9 +895,11 @@ impl State { |
| 884 | 895 | Spec { |
| 885 | 896 | size: [fill(), px(ui::shell::TOOL)], |
| 886 | 897 | pad: [4.0, 0.0], |
| 898 | role: Some(Role::SearchInput), | |
| 887 | 899 | ..Spec::default() |
| 888 | 900 | }, |
| 889 | 901 | ); |
| 902 | name(&mut self.ui, field(), "Search"); | |
| 890 | 903 | self.scope_button(theme); |
| 891 | 904 | self.ui.close(); |
| 892 | 905 | let query = self.search.query.trim().to_owned(); |
| ... | ... | @@ -935,6 +948,7 @@ impl State { |
| 935 | 948 | size: [fit(), px(22.0)], |
| 936 | 949 | text: Some(&scope), |
| 937 | 950 | color: Some(theme.accent), |
| 951 | role: Some(Role::Button), | |
| 938 | 952 | ..Spec::default() |
| 939 | 953 | }, |
| 940 | 954 | ) |
| ... | ... | @@ -988,6 +1002,7 @@ impl State { |
| 988 | 1002 | Spec { |
| 989 | 1003 | size: [fill(), px(view)], |
| 990 | 1004 | fill: Some(theme.popup), |
| 1005 | role: Some(Role::ListBox), | |
| 991 | 1006 | ..Spec::default() |
| 992 | 1007 | }, |
| 993 | 1008 | ui::List { |
| ... | ... | @@ -998,6 +1013,10 @@ impl State { |
| 998 | 1013 | }, |
| 999 | 1014 | &mut self.search.selected, |
| 1000 | 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 | 1020 | let found = &found[row.index]; |
| 1002 | 1021 | if row.index == 0 || row.index == titled { |
| 1003 | 1022 | let heading = if found.in_title { |
| ... | ... | @@ -1022,6 +1041,11 @@ impl State { |
| 1022 | 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 | 1049 | // Arrows show the page they select, as OneNote previews it. |
| 1026 | 1050 | if before != self.search.selected |
| 1027 | 1051 | && keys.iter().any(|key| *key != NamedKey::Enter) |
| ... | ... | @@ -1053,6 +1077,7 @@ impl State { |
| 1053 | 1077 | text: Some(&link), |
| 1054 | 1078 | color: Some(theme.accent), |
| 1055 | 1079 | pad: [6.0, 0.0], |
| 1080 | role: Some(Role::Link), | |
| 1056 | 1081 | ..Spec::default() |
| 1057 | 1082 | }, |
| 1058 | 1083 | ) |
| ... | ... | @@ -1106,16 +1131,20 @@ impl State { |
| 1106 | 1131 | ); |
| 1107 | 1132 | let mut step = None; |
| 1108 | 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 | 1135 | .clicked |
| 1111 | 1136 | { |
| 1112 | 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 | 1142 | .clicked |
| 1116 | 1143 | { |
| 1117 | 1144 | step = Some(true); |
| 1118 | 1145 | } |
| 1146 | let button = self.ui.id("next"); | |
| 1147 | name(&mut self.ui, button, "Next Match"); | |
| 1119 | 1148 | } |
| 1120 | 1149 | ui::text_field( |
| 1121 | 1150 | &mut self.ui, |
| ... | ... | @@ -1125,14 +1154,19 @@ impl State { |
| 1125 | 1154 | Spec { |
| 1126 | 1155 | size: [fill(), px(ui::shell::TOOL)], |
| 1127 | 1156 | pad: [4.0, 0.0], |
| 1157 | role: Some(Role::SearchInput), | |
| 1128 | 1158 | ..Spec::default() |
| 1129 | 1159 | }, |
| 1130 | 1160 | ); |
| 1161 | name(&mut self.ui, find_field(), "Find on Page"); | |
| 1131 | 1162 | if std::mem::take(&mut self.search.claim) { |
| 1132 | 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).clicked | |
| 1135 | { | |
| 1165 | let close = | |
| 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 | 1170 | self.end_search(); |
| 1137 | 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 | ||
| 5 | use crate::library::{Library, Login, Mount}; | |
| 6 | use crate::{State, art, platform}; | |
| 7 | use accesskit::Role; | |
| 8 | use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal}; | |
| 9 | use std::{io, path::Path, sync::mpsc, time::Duration}; | |
| 10 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; | |
| 11 | use winit::keyboard::NamedKey; | |
| 12 | ||
| 13 | const WIDTH: f32 = 480.0; | |
| 14 | /// The rows the folder list shows at once. | |
| 15 | const ROWS: f32 = 9.0; | |
| 16 | /// How long the server may take to answer before it counts as unreachable. | |
| 17 | const TIMEOUT: Duration = Duration::from_secs(10); | |
| 18 | /// The most entries a folder lists. | |
| 19 | const LIMIT: usize = 10_000; | |
| 20 | ||
| 21 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 22 | enum Step { | |
| 23 | Address, | |
| 24 | SignIn, | |
| 25 | Browse, | |
| 26 | } | |
| 27 | ||
| 28 | /// The dialog's fields and where it has reached. | |
| 29 | pub 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)] | |
| 51 | pub 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)] | |
| 61 | enum 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`. | |
| 68 | pub struct Request { | |
| 69 | asked: u64, | |
| 70 | mount: Mount, | |
| 71 | login: Login, | |
| 72 | } | |
| 73 | ||
| 74 | pub 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. | |
| 81 | pub 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 | ||
| 86 | struct Embedded; | |
| 87 | ||
| 88 | fn credentials(login: &Login) -> Credentials<'_> { | |
| 89 | Credentials { | |
| 90 | username: &login.user, | |
| 91 | password: &login.password, | |
| 92 | domain: &login.domain, | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | impl 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 | ||
| 107 | impl 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. | |
| 130 | fn 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. | |
| 158 | pub 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 | ||
| 195 | impl 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 | ||
| 338 | fn id() -> Id { | |
| 339 | Id::ROOT.child("server") | |
| 340 | } | |
| 341 | ||
| 342 | fn address_field() -> Id { | |
| 343 | id().child("address") | |
| 344 | } | |
| 345 | ||
| 346 | fn user_field() -> Id { | |
| 347 | id().child("user") | |
| 348 | } | |
| 349 | ||
| 350 | fn password_field() -> Id { | |
| 351 | id().child("password") | |
| 352 | } | |
| 353 | ||
| 354 | fn domain_field() -> Id { | |
| 355 | id().child("domain") | |
| 356 | } | |
| 357 | ||
| 358 | impl 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. | |
| 772 | fn 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 | ||
| 794 | fn 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)] | |
| 801 | mod 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 | 22 | pub color_scheme: ColorScheme, |
| 23 | 23 | /// Keeps pages white in the Dark color scheme. |
| 24 | 24 | pub light_pages: bool, |
| 25 | /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. | |
| 26 | pub hide_spelling: bool, | |
| 25 | 27 | /// Where searches look first, as "Set This Scope as Default" chose. |
| 26 | 28 | pub search_scope: crate::search::Scope, |
| 27 | 29 | /// The tag list Customize Tags edits; none keeps OneNote's. |
| 28 | 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 | } |
| 30 | 34 | |
| 31 | 35 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | ... | @@ -132,8 +136,10 @@ impl crate::State { |
| 132 | 136 | user_name: Some(self.author.clone()), |
| 133 | 137 | color_scheme: self.color_scheme, |
| 134 | 138 | light_pages: self.light_pages, |
| 139 | hide_spelling: self.hide_spelling, | |
| 135 | 140 | search_scope: self.search.default, |
| 136 | 141 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), |
| 142 | manual_updates: !self.updates.automatic(), | |
| 137 | 143 | }; |
| 138 | 144 | if let Err(error) = settings.save(path) { |
| 139 | 145 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | ... | @@ -183,13 +189,16 @@ mod tests { |
| 183 | 189 | user_name: Some("Snowbound Test".into()), |
| 184 | 190 | color_scheme: ColorScheme::Dark, |
| 185 | 191 | light_pages: true, |
| 192 | hide_spelling: true, | |
| 186 | 193 | search_scope: crate::search::Scope::Notebook, |
| 187 | 194 | tags: Some(vec![canvas::editor::NoteTag { |
| 188 | 195 | label: "Snow check".into(), |
| 189 | 196 | shape: 61, |
| 190 | 197 | color: Some(0x0000_0080), |
| 191 | 198 | highlight: Some(0x00ff_cc00), |
| 199 | art: Some(format!("{}.png", "ab".repeat(32))), | |
| 192 | 200 | }]), |
| 201 | manual_updates: true, | |
| 193 | 202 | }; |
| 194 | 203 | settings.save(&path).unwrap(); |
| 195 | 204 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+141-36| ... | ... | @@ -7,13 +7,16 @@ use crate::{ |
| 7 | 7 | menus::{Dragged, Target}, |
| 8 | 8 | rename, section_color, |
| 9 | 9 | }; |
| 10 | use notebook::discover::{Folder, SectionState}; | |
| 10 | use notebook::discover::{Folder, Reason, SectionState}; | |
| 11 | 11 | use std::{collections::HashSet, sync::Arc}; |
| 12 | 12 | use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; |
| 13 | 13 | |
| 14 | 14 | pub const WIDTH: f32 = 220.0; |
| 15 | 15 | /// The square the sidebar's button stands in at the start of the section tabs. |
| 16 | 16 | const 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. | |
| 19 | const DROP: f32 = 2.0; | |
| 17 | 20 | const ROW: f32 = 24.0; |
| 18 | 21 | /// How far each level of the tree sits inside its parent. |
| 19 | 22 | const INDENT: f32 = 16.0; |
| ... | ... | @@ -29,13 +32,15 @@ pub enum Action { |
| 29 | 32 | Fold(String), |
| 30 | 33 | NewNotebook, |
| 31 | 34 | OpenNotebook, |
| 35 | /// Signs in again to the notebook opened from its server at this location. | |
| 36 | SignIn(String), | |
| 32 | 37 | /// Opens the Options dialog, which macOS reaches from the application menu instead. |
| 33 | 38 | Options, |
| 34 | 39 | /// A row's context menu, opened here. |
| 35 | 40 | Menu(Target, [f32; 2]), |
| 36 | 41 | /// Ends renaming, with the name typed or without. |
| 37 | 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 | 44 | Unavailable(String, String), |
| 40 | 45 | } |
| 41 | 46 | |
| ... | ... | @@ -126,15 +131,32 @@ impl Entry { |
| 126 | 131 | } |
| 127 | 132 | } |
| 128 | 133 | |
| 129 | /// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with | |
| 130 | /// the open section marked. | |
| 131 | fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) { | |
| 134 | /// The header, whose empty space drags the window as the tab row's does. | |
| 135 | pub fn header() -> Id { | |
| 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. | |
| 141 | fn sidebar( | |
| 142 | ui: &mut Ui, | |
| 143 | tree: &mut Tree, | |
| 144 | notebooks: &[Arc<Library>], | |
| 145 | header: f32, | |
| 146 | drags: bool, | |
| 147 | rows: bool, | |
| 148 | ) { | |
| 132 | 149 | let (theme, folded) = (tree.theme, tree.folded); |
| 133 | ui.open( | |
| 134 | "header", | |
| 150 | ui.open_as( | |
| 151 | self::header(), | |
| 135 | 152 | Spec { |
| 153 | flags: if drags { | |
| 154 | Flags::CLICKABLE | |
| 155 | } else { | |
| 156 | Flags::default() | |
| 157 | }, | |
| 136 | 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 | 160 | gap: 6.0, |
| 139 | 161 | ..Spec::default() |
| 140 | 162 | }, |
| ... | ... | @@ -179,7 +201,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 179 | 201 | ui.id(("notebook", index)), |
| 180 | 202 | Row { |
| 181 | 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 | 209 | depth: 0, |
| 184 | 210 | dim: library.notebook.is_err(), |
| 185 | 211 | fold: Some(unfolded), |
| ... | ... | @@ -187,7 +213,11 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 187 | 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 | 221 | tree.action = Some(Action::Fold(key)); |
| 192 | 222 | } |
| 193 | 223 | if let Some(point) = row.context { |
| ... | ... | @@ -378,7 +408,16 @@ fn folder( |
| 378 | 408 | }, |
| 379 | 409 | ); |
| 380 | 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 | 589 | pad: [6.0 + INDENT * row.depth as f32, 0.0], |
| 551 | 590 | // The field's text stands where the label did. |
| 552 | 591 | gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 }, |
| 592 | role: Some(accesskit::Role::TreeItem), | |
| 553 | 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 | 604 | let color = if row.dim { theme.text_dim } else { theme.text }; |
| 557 | 605 | let (icon, [red, green, blue, _]) = match row.icon { |
| 558 | 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 | 647 | ui::shell::CHEVRON |
| 600 | 648 | }), |
| 601 | 649 | color: Some(theme.text_dim), |
| 650 | role: Some(accesskit::Role::Button), | |
| 602 | 651 | ..Spec::default() |
| 603 | 652 | }, |
| 604 | 653 | ) |
| 605 | 654 | .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 | 661 | ui.close(); |
| 608 | 662 | (ui.signal(id), folded) |
| 609 | 663 | } |
| ... | ... | @@ -638,9 +692,11 @@ impl crate::State { |
| 638 | 692 | Spec { |
| 639 | 693 | axis: Axis::Y, |
| 640 | 694 | size: [px(WIDTH), fill()], |
| 695 | role: Some(accesskit::Role::Tree), | |
| 641 | 696 | ..Spec::default() |
| 642 | 697 | }, |
| 643 | 698 | ); |
| 699 | crate::name(&mut self.ui, rows_id, "Notebooks"); | |
| 644 | 700 | let open = self.session.as_ref().and_then(|session| { |
| 645 | 701 | let index = self |
| 646 | 702 | .notebooks |
| ... | ... | @@ -648,6 +704,7 @@ impl crate::State { |
| 648 | 704 | .position(|library| Arc::ptr_eq(library, &session.library))?; |
| 649 | 705 | Some((index, session.tabs[session.tab].path.as_str())) |
| 650 | 706 | }); |
| 707 | let drags = self.chrome_drags(); | |
| 651 | 708 | let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); |
| 652 | 709 | tree.lifted = self |
| 653 | 710 | .drag |
| ... | ... | @@ -662,6 +719,7 @@ impl crate::State { |
| 662 | 719 | &mut tree, |
| 663 | 720 | &self.notebooks, |
| 664 | 721 | crate::TAB_ROW, |
| 722 | drags, | |
| 665 | 723 | width > 0.5, |
| 666 | 724 | ); |
| 667 | 725 | let Tree { |
| ... | ... | @@ -697,11 +755,11 @@ impl crate::State { |
| 697 | 755 | Some(Action::Renamed(keep)) => self.finish_renaming(keep), |
| 698 | 756 | Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), |
| 699 | 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 | 761 | Some(Action::Options) => self.open_options(), |
| 701 | Some(Action::Unavailable(name, error)) => crate::platform::alert( | |
| 702 | &format!("Can't read \u{201c}{name}\u{201d}"), | |
| 703 | &format!("{error}\n\nIt appears here once you have access."), | |
| 704 | ), | |
| 762 | Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message), | |
| 705 | 763 | Some(Action::Open { .. }) | None => {} |
| 706 | 764 | } |
| 707 | 765 | width |
| ... | ... | @@ -721,12 +779,14 @@ impl crate::State { |
| 721 | 779 | Spec { |
| 722 | 780 | flags: Flags::FLOAT | Flags::CLIP, |
| 723 | 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 | 786 | ..Spec::default() |
| 726 | 787 | }, |
| 727 | 788 | ); |
| 728 | if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, false).clicked | |
| 729 | { | |
| 789 | if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, None).clicked { | |
| 730 | 790 | self.sidebar = !self.sidebar; |
| 731 | 791 | self.save_settings(); |
| 732 | 792 | } |
| ... | ... | @@ -955,10 +1015,23 @@ impl crate::State { |
| 955 | 1015 | /// Shows the notebook at `location` at `section`, or its first section, opening it |
| 956 | 1016 | /// unless it is open. |
| 957 | 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 | 1031 | let open = self |
| 959 | 1032 | .notebooks |
| 960 | 1033 | .iter() |
| 961 | .find(|library| library.location == location) | |
| 1034 | .find(|library| library.location == location && library.notebook.is_ok()) | |
| 962 | 1035 | .cloned(); |
| 963 | 1036 | // The notebook shown already shows a section, which only one reader may hold. |
| 964 | 1037 | if let Some(session) = &self.session |
| ... | ... | @@ -971,7 +1044,10 @@ impl crate::State { |
| 971 | 1044 | } |
| 972 | 1045 | let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); |
| 973 | 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 | 1051 | if let Err(error) = &library.notebook { |
| 976 | 1052 | return Err(error.clone().into()); |
| 977 | 1053 | } |
| ... | ... | @@ -989,25 +1065,52 @@ impl crate::State { |
| 989 | 1065 | /// what is missing, and the two ways to start. |
| 990 | 1066 | pub(crate) fn welcome(&mut self, theme: &Theme) { |
| 991 | 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 | 1077 | self.notice( |
| 993 | 1078 | theme, |
| 994 | 1079 | id, |
| 995 | 1080 | theme.base, |
| 996 | 1081 | "No notebooks open", |
| 997 | vec![ | |
| 998 | ( | |
| 999 | "new", | |
| 1000 | art::PLUS, | |
| 1001 | "New Notebook", | |
| 1002 | crate::Command::NewNotebook, | |
| 1003 | ), | |
| 1004 | ( | |
| 1005 | "open", | |
| 1006 | art::NOTEBOOK, | |
| 1007 | "Open Existing", | |
| 1008 | crate::Command::OpenNotebook, | |
| 1009 | ), | |
| 1010 | ], | |
| 1082 | crate::icloud::folder() | |
| 1083 | .map(|_| { | |
| 1084 | ( | |
| 1085 | "icloud", | |
| 1086 | art::ICLOUD, | |
| 1087 | "Use iCloud Drive", | |
| 1088 | crate::Command::UseICloud, | |
| 1089 | ) | |
| 1090 | }) | |
| 1091 | .into_iter() | |
| 1092 | .chain([ | |
| 1093 | ( | |
| 1094 | "new", | |
| 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 | } |
| 1013 | 1116 | |
| ... | ... | @@ -1020,9 +1123,10 @@ impl crate::State { |
| 1020 | 1123 | folder: String::new(), |
| 1021 | 1124 | }, |
| 1022 | 1125 | ); |
| 1126 | let id = self.ui.id("no sections"); | |
| 1023 | 1127 | self.notice( |
| 1024 | 1128 | theme, |
| 1025 | crate::page(), | |
| 1129 | id, | |
| 1026 | 1130 | theme.strip, |
| 1027 | 1131 | "No sections in this notebook", |
| 1028 | 1132 | vec![("new", art::PLUS, "New Section", new)], |
| ... | ... | @@ -1039,7 +1143,7 @@ impl crate::State { |
| 1039 | 1143 | title: &str, |
| 1040 | 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 | 1147 | let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default(); |
| 1044 | 1148 | self.ui.open_as( |
| 1045 | 1149 | id, |
| ... | ... | @@ -1086,6 +1190,7 @@ impl crate::State { |
| 1086 | 1190 | hover_border: Some(theme.accent), |
| 1087 | 1191 | radius: 6.0, |
| 1088 | 1192 | center: true, |
| 1193 | role: Some(accesskit::Role::Button), | |
| 1089 | 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 | ||
| 4 | use canvas::spelling::{Dictionary, pick}; | |
| 5 | use std::collections::HashMap; | |
| 6 | use std::ffi::{CStr, CString, c_char, c_int, c_void}; | |
| 7 | use std::sync::Mutex; | |
| 8 | ||
| 9 | type Broker = *mut c_void; | |
| 10 | type Dict = *mut c_void; | |
| 11 | type Describe = | |
| 12 | unsafe extern "C" fn(*const c_char, *const c_char, *const c_char, *const c_char, *mut c_void); | |
| 13 | ||
| 14 | struct 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 | ||
| 23 | struct 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. | |
| 34 | unsafe impl Send for Enchant {} | |
| 35 | ||
| 36 | impl 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 | ||
| 50 | struct Checker(Mutex<Enchant>); | |
| 51 | ||
| 52 | impl 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`. | |
| 102 | unsafe 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 | ||
| 107 | unsafe 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. | |
| 124 | pub 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)] | |
| 156 | mod 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 | ||
| 4 | use canvas::spelling::{Dictionary, pick}; | |
| 5 | use objc2::{class, msg_send, msg_send_id, rc::Retained, runtime::AnyObject}; | |
| 6 | use objc2_foundation::{MainThreadMarker, NSArray, NSRange, NSString}; | |
| 7 | use std::ffi::c_void; | |
| 8 | ||
| 9 | unsafe 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. | |
| 16 | fn 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 | ||
| 35 | fn checker() -> Retained<AnyObject> { | |
| 36 | unsafe { msg_send_id![class!(NSSpellChecker), sharedSpellChecker] } | |
| 37 | } | |
| 38 | ||
| 39 | struct 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 | ||
| 46 | impl Checker { | |
| 47 | fn language(&self, lcid: u32) -> Option<Retained<NSString>> { | |
| 48 | pick(lcid, &self.languages).map(NSString::from_str) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | impl 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. | |
| 104 | pub 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 | 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook |
| 2 | 2 | //! Synchronization for the open section's notebook and each of its sections. |
| 3 | 3 | |
| 4 | use crate::{Session, State, art, filetime, library, platform}; | |
| 4 | use crate::{Session, State, art, filetime, library, platform, update}; | |
| 5 | 5 | use notebook::session::{SyncState, SyncStatus}; |
| 6 | 6 | use std::error::Error; |
| 7 | 7 | use ui::{ |
| ... | ... | @@ -45,6 +45,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option |
| 45 | 45 | art::SYNC_OFFLINE, |
| 46 | 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 | 54 | SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), |
| 49 | 55 | SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None), |
| 50 | 56 | SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None), |
| ... | ... | @@ -115,8 +121,21 @@ fn when(time: u64) -> String { |
| 115 | 121 | } |
| 116 | 122 | } |
| 117 | 123 | |
| 118 | /// The status's icon in the toolbar, named in its tooltip, which opens the popup. | |
| 119 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { | |
| 124 | const TITLE: &str = "Notebook Sync Status"; | |
| 125 | ||
| 126 | /// What the popup says of a newer build: downloading, or waiting to be installed. | |
| 127 | fn 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. | |
| 138 | pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { | |
| 120 | 139 | let sync = overall(&sections(session)); |
| 121 | 140 | let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline(); |
| 122 | 141 | let (label, icon, _) = describe(&sync); |
| ... | ... | @@ -130,11 +149,40 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { |
| 130 | 149 | hover_fill: Some(theme.hover()), |
| 131 | 150 | radius: 4.0, |
| 132 | 151 | center: true, |
| 152 | role: Some(accesskit::Role::Button), | |
| 133 | 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 | 184 | ui.close(); |
| 137 | ui::popup::tooltip(ui, label, "", None); | |
| 185 | ui::popup::tooltip(ui, label, "", note.as_deref()); | |
| 138 | 186 | if ui.signal(button()).clicked { |
| 139 | 187 | if ui.popup_open(id()) { |
| 140 | 188 | ui.close_popup(id()); |
| ... | ... | @@ -148,6 +196,7 @@ impl State { |
| 148 | 196 | /// Builds the sync status popup while it is open. |
| 149 | 197 | pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> { |
| 150 | 198 | let (ui, notebooks) = (&mut self.ui, &self.notebooks); |
| 199 | let update = self.updates.status(); | |
| 151 | 200 | let Some(session) = &mut self.session else { |
| 152 | 201 | ui.close_popup(id()); |
| 153 | 202 | return Ok(()); |
| ... | ... | @@ -174,9 +223,13 @@ impl State { |
| 174 | 223 | pad: [14.0, 10.0], |
| 175 | 224 | gap: 2.0, |
| 176 | 225 | anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())), |
| 226 | role: Some(accesskit::Role::Dialog), | |
| 177 | 227 | ..Spec::default() |
| 178 | 228 | }, |
| 179 | 229 | ); |
| 230 | if let Some(node) = ui.access(id()) { | |
| 231 | node.set_label(TITLE); | |
| 232 | } | |
| 180 | 233 | let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| { |
| 181 | 234 | ui.leaf( |
| 182 | 235 | part, |
| ... | ... | @@ -220,7 +273,7 @@ impl State { |
| 220 | 273 | ); |
| 221 | 274 | ui.close(); |
| 222 | 275 | }; |
| 223 | text(ui, "title", "Notebook Sync Status", theme.text, true); | |
| 276 | text(ui, "title", TITLE, theme.text, true); | |
| 224 | 277 | row(ui, "Notebook", &session.library.name); |
| 225 | 278 | row(ui, "Location", &session.library.location); |
| 226 | 279 | row(ui, "Connection", &session.library.transport()); |
| ... | ... | @@ -277,6 +330,32 @@ impl State { |
| 277 | 330 | let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); |
| 278 | 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 | 359 | ui.open( |
| 281 | 360 | "controls", |
| 282 | 361 | Spec { |
| ... | ... | @@ -308,9 +387,13 @@ impl State { |
| 308 | 387 | radius: 4.0, |
| 309 | 388 | pad: [theme.font_size * 0.75, 0.0], |
| 310 | 389 | center: true, |
| 390 | role: Some(accesskit::Role::Button), | |
| 311 | 391 | ..Spec::default() |
| 312 | 392 | }, |
| 313 | 393 | ); |
| 394 | if let Some(node) = ui.access(ui.id("show-file")) { | |
| 395 | node.set_disabled(); | |
| 396 | } | |
| 314 | 397 | false |
| 315 | 398 | } |
| 316 | 399 | }; |
| ... | ... | @@ -331,6 +414,17 @@ impl State { |
| 331 | 414 | if let Some(file) = file.filter(|_| show) { |
| 332 | 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 | 428 | Ok(()) |
| 335 | 429 | } |
| 336 | 430 | } |
crates/snowbound/src/tags.rs+601-35| ... | ... | @@ -2,10 +2,12 @@ |
| 2 | 2 | //! the user's tag list: the tags the toolbar, the menus and Ctrl+1 to Ctrl+9 apply. |
| 3 | 3 | |
| 4 | 4 | use crate::{State, commands, platform}; |
| 5 | use accesskit::Role; | |
| 5 | 6 | use canvas::editor::NoteTag; |
| 6 | 7 | use canvas::gpu::{colorref, tag_sources}; |
| 7 | 8 | use canvas::outline::{TagIcon, symbol_name}; |
| 8 | 9 | use draw::edit::Platform; |
| 10 | use notebook::sidecar::art_name; | |
| 9 | 11 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; |
| 10 | 12 | use winit::keyboard::NamedKey; |
| 11 | 13 | |
| ... | ... | @@ -42,21 +44,132 @@ const GALLERY: [[u16; 15]; 10] = [ |
| 42 | 44 | ], |
| 43 | 45 | ]; |
| 44 | 46 | |
| 45 | /// Font Color's swatches, Office's forty in their grid, as COLORREFs. | |
| 46 | const FONT_COLORS: [u32; 40] = [ | |
| 47 | 0x000000, 0x003399, 0x003333, 0x003300, 0x663300, 0x800000, 0x993333, 0x333333, // | |
| 48 | 0x000080, 0x0066ff, 0x008080, 0x008000, 0x808000, 0xff0000, 0x996666, 0x808080, // | |
| 49 | 0x0000ff, 0x0099ff, 0x00cc99, 0x669933, 0xcccc33, 0xff6633, 0x800080, 0x969696, // | |
| 50 | 0xff00ff, 0x00ccff, 0x00ffff, 0x00ff00, 0xffff00, 0xffcc00, 0x663399, 0xc0c0c0, // | |
| 51 | 0xcc99ff, 0x99ccff, 0x99ffff, 0xccffcc, 0xffffcc, 0xffcc99, 0xff99cc, 0xffffff, | |
| 47 | /// Snowbound's own symbols past OneNote's: their names, the symbols OneNote shows in their | |
| 48 | /// place, and their art, which a notebook keeps as it keeps a picture chosen for a tag. | |
| 49 | const EXTRAS: [(&str, u16, &str); 15] = [ | |
| 50 | ( | |
| 51 | "Snowflake", | |
| 52 | 34, | |
| 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 | ]; |
| 53 | 82 | |
| 54 | /// Highlight Color's swatches as COLORREFs, which differ from the highlighter's in Sky Blue | |
| 55 | /// and Dark Green. | |
| 56 | const HIGHLIGHTS: [u32; 15] = [ | |
| 57 | 0x00ffff, 0x00ff00, 0xffcc00, 0xff00ff, 0xff0000, // | |
| 58 | 0x0000ff, 0x800000, 0x808000, 0x003300, 0x800080, // | |
| 59 | 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000, | |
| 83 | /// The symbol OneNote shows for a tag drawn with a picture chosen for it, until another is | |
| 84 | /// picked. | |
| 85 | const PICTURE_FALLBACK: u16 = 36; | |
| 86 | ||
| 87 | /// The names a notebook keeps `EXTRAS`' art under. | |
| 88 | fn 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. | |
| 99 | fn 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. | |
| 104 | pub(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. | |
| 112 | const 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. | |
| 157 | const 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 | ]; |
| 61 | 174 | |
| 62 | 175 | /// The Customize Tags dialog's list while it is open. |
| ... | ... | @@ -67,6 +180,15 @@ pub struct TagList { |
| 67 | 180 | editor: Option<(NoteTag, Option<usize>)>, |
| 68 | 181 | } |
| 69 | 182 | |
| 183 | /// What the Symbol gallery picked. | |
| 184 | enum Pick { | |
| 185 | Symbol(u16), | |
| 186 | /// One of `EXTRAS`. | |
| 187 | Extra(usize), | |
| 188 | /// Custom Image…, which asks for a picture. | |
| 189 | Picture, | |
| 190 | } | |
| 191 | ||
| 70 | 192 | fn id() -> Id { |
| 71 | 193 | Id::ROOT.child("customize-tags") |
| 72 | 194 | } |
| ... | ... | @@ -83,9 +205,13 @@ fn popup(name: &str) -> Id { |
| 83 | 205 | editor_id().child(("popup", name)) |
| 84 | 206 | } |
| 85 | 207 | |
| 86 | /// A tag's artwork before its name: its symbol, or none. | |
| 87 | fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> { | |
| 88 | TagIcon::of(tag.shape, false).map(tag_sources) | |
| 208 | /// A tag's artwork: its own art, else its symbol, or none. | |
| 209 | pub(crate) fn artwork(tag: &NoteTag) -> Option<&'static [&'static str]> { | |
| 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 | } |
| 90 | 216 | |
| 91 | 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 | 258 | radius: 4.0, |
| 133 | 259 | pad: [theme.font_size * 0.75, 0.0], |
| 134 | 260 | center: true, |
| 261 | role: Some(Role::Button), | |
| 135 | 262 | ..Spec::default() |
| 136 | 263 | }, |
| 137 | 264 | ); |
| 265 | if let Some(node) = ui.access(ui.id(part)) { | |
| 266 | node.set_disabled(); | |
| 267 | } | |
| 138 | 268 | false |
| 139 | 269 | } |
| 140 | 270 | |
| ... | ... | @@ -144,18 +274,20 @@ fn tool(ui: &mut Ui, part: &str, art: &'static [&'static str], tip: &str, enable |
| 144 | 274 | let text = ui.theme.text; |
| 145 | 275 | if !enabled { |
| 146 | 276 | ui::shell::unavailable(ui, part, art, text, false); |
| 277 | ui::popup::tooltip(ui, tip, "", None); | |
| 147 | 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 | 281 | ui::popup::tooltip(ui, tip, "", None); |
| 151 | 282 | clicked |
| 152 | 283 | } |
| 153 | 284 | |
| 154 | /// A button showing `art` in its own colours, with `bar` under it, and a menu arrow; opens | |
| 155 | /// popup `menu` and returns where it opens. | |
| 285 | /// A button `name`d, showing `art` in its own colours, with `bar` under it, and a menu | |
| 286 | /// arrow; opens popup `menu` and returns where it opens. | |
| 156 | 287 | fn picker( |
| 157 | 288 | ui: &mut Ui, |
| 158 | 289 | part: &str, |
| 290 | name: &str, | |
| 159 | 291 | art: Option<&'static [&'static str]>, |
| 160 | 292 | bar: Option<[f32; 4]>, |
| 161 | 293 | menu: Id, |
| ... | ... | @@ -172,9 +304,15 @@ fn picker( |
| 172 | 304 | hover_fill: Some(theme.hover()), |
| 173 | 305 | border: Some(theme.chip), |
| 174 | 306 | radius: 4.0, |
| 307 | role: Some(Role::Button), | |
| 175 | 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 | 316 | ui.open( |
| 179 | 317 | "icon", |
| 180 | 318 | Spec { |
| ... | ... | @@ -242,15 +380,20 @@ fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { |
| 242 | 380 | pad: [14.0, 10.0], |
| 243 | 381 | gap: 6.0, |
| 244 | 382 | anchor: Some(Anchor::Dialog), |
| 383 | role: Some(Role::Dialog), | |
| 245 | 384 | ..Spec::default() |
| 246 | 385 | }, |
| 247 | 386 | ); |
| 387 | if let Some(node) = ui.access(id) { | |
| 388 | node.set_label(title); | |
| 389 | } | |
| 248 | 390 | ui.leaf( |
| 249 | 391 | "title", |
| 250 | 392 | Spec { |
| 251 | 393 | size: [fill(), px(ROW + 4.0)], |
| 252 | 394 | text: Some(title), |
| 253 | 395 | bold: true, |
| 396 | role: Some(Role::Heading), | |
| 254 | 397 | ..Spec::default() |
| 255 | 398 | }, |
| 256 | 399 | ); |
| ... | ... | @@ -307,6 +450,7 @@ impl State { |
| 307 | 450 | border: Some(theme.chip), |
| 308 | 451 | radius: 4.0, |
| 309 | 452 | pad: [2.0, 2.0], |
| 453 | role: Some(Role::List), | |
| 310 | 454 | ..Spec::default() |
| 311 | 455 | }, |
| 312 | 456 | ); |
| ... | ... | @@ -321,9 +465,13 @@ impl State { |
| 321 | 465 | fill: selected.then_some(theme.selection), |
| 322 | 466 | hover_fill: Some(theme.hover()), |
| 323 | 467 | radius: 3.0, |
| 468 | role: Some(Role::ListItem), | |
| 324 | 469 | ..Spec::default() |
| 325 | 470 | }, |
| 326 | 471 | ); |
| 472 | if let Some(node) = ui.access(row) { | |
| 473 | node.set_selected(selected); | |
| 474 | } | |
| 327 | 475 | // OneNote's list marks a tag without a symbol with its "None" symbol art. |
| 328 | 476 | match artwork(tag) { |
| 329 | 477 | Some(art) => icon(ui, "icon", Some(art), [1.0; 4]), |
| ... | ... | @@ -420,6 +568,7 @@ impl State { |
| 420 | 568 | shape: 0, |
| 421 | 569 | color: None, |
| 422 | 570 | highlight: None, |
| 571 | art: None, | |
| 423 | 572 | }; |
| 424 | 573 | list.editor = Some((tag, None)); |
| 425 | 574 | } else if let Some(place) = modify { |
| ... | ... | @@ -429,7 +578,8 @@ impl State { |
| 429 | 578 | ui.open_popup(editor_id()); |
| 430 | 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 | 583 | match place { |
| 434 | 584 | Some(place) => list.tags[place] = tag, |
| 435 | 585 | None => { |
| ... | ... | @@ -439,6 +589,9 @@ impl State { |
| 439 | 589 | } |
| 440 | 590 | } |
| 441 | 591 | ui.close(); |
| 592 | if picking { | |
| 593 | self.commands.push(crate::Command::TagPicture); | |
| 594 | } | |
| 442 | 595 | if ok { |
| 443 | 596 | self.tags = list.tags.clone(); |
| 444 | 597 | self.save_settings(); |
| ... | ... | @@ -452,11 +605,64 @@ impl State { |
| 452 | 605 | } |
| 453 | 606 | } |
| 454 | 607 | |
| 608 | impl 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 | 659 | /// 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. | |
| 457 | 662 | fn tag_editor( |
| 458 | 663 | ui: &mut Ui, |
| 459 | 664 | editor: &mut Option<(NoteTag, Option<usize>)>, |
| 665 | picking: &mut bool, | |
| 460 | 666 | ) -> Option<(NoteTag, Option<usize>)> { |
| 461 | 667 | if !ui.popup_open(editor_id()) { |
| 462 | 668 | *editor = None; |
| ... | ... | @@ -506,6 +712,9 @@ fn tag_editor( |
| 506 | 712 | ..Spec::default() |
| 507 | 713 | }, |
| 508 | 714 | ); |
| 715 | if let Some(node) = ui.access(name_field()) { | |
| 716 | node.set_label("Display name"); | |
| 717 | } | |
| 509 | 718 | ui.open( |
| 510 | 719 | "pickers", |
| 511 | 720 | Spec { |
| ... | ... | @@ -535,10 +744,31 @@ fn tag_editor( |
| 535 | 744 | }; |
| 536 | 745 | column(ui, "symbol", "Symbol:"); |
| 537 | 746 | let symbol = artwork(tag).unwrap_or(crate::art::FONT_COLOR); |
| 538 | let tint = if tag.shape == 0 { theme.text } else { [1.0; 4] }; | |
| 539 | let anchor = picker(ui, "picker", Some(symbol), None, popup("symbol"), tint); | |
| 540 | if let Some(shape) = gallery(ui, anchor) { | |
| 541 | tag.shape = shape; | |
| 747 | let tint = if artwork(tag).is_none() { | |
| 748 | theme.text | |
| 749 | } else { | |
| 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 | 773 | ui.close(); |
| 544 | 774 | column(ui, "color", "Font Color:"); |
| ... | ... | @@ -546,6 +776,7 @@ fn tag_editor( |
| 546 | 776 | let anchor = picker( |
| 547 | 777 | ui, |
| 548 | 778 | "picker", |
| 779 | "Font Color", | |
| 549 | 780 | Some(crate::art::FONT_COLOR), |
| 550 | 781 | Some(bar), |
| 551 | 782 | popup("color"), |
| ... | ... | @@ -560,6 +791,7 @@ fn tag_editor( |
| 560 | 791 | let anchor = picker( |
| 561 | 792 | ui, |
| 562 | 793 | "picker", |
| 794 | "Highlight Color", | |
| 563 | 795 | Some(crate::art::HIGHLIGHTER), |
| 564 | 796 | bar, |
| 565 | 797 | popup("highlight"), |
| ... | ... | @@ -570,6 +802,45 @@ fn tag_editor( |
| 570 | 802 | } |
| 571 | 803 | ui.close(); |
| 572 | 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 | 844 | caption(ui, "preview-label", "Preview"); |
| 574 | 845 | ui.open( |
| 575 | 846 | "preview", |
| ... | ... | @@ -629,9 +900,10 @@ fn tag_editor( |
| 629 | 900 | None |
| 630 | 901 | } |
| 631 | 902 | |
| 632 | /// Symbol's gallery below `anchor` while it is open. Returns the symbol picked, 0 for None. | |
| 633 | fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { | |
| 634 | let id = popup("symbol"); | |
| 903 | /// Popup `id`, a gallery of OneNote's symbols below `anchor`, while it is open; with | |
| 904 | /// `snowbound`, also None, Snowbound's own symbols and Custom Image…. Returns the pick, | |
| 905 | /// symbol 0 for None. | |
| 906 | fn gallery(ui: &mut Ui, id: Id, anchor: Anchor, snowbound: bool) -> Option<Pick> { | |
| 635 | 907 | if !ui.popup_open(id) { |
| 636 | 908 | return None; |
| 637 | 909 | } |
| ... | ... | @@ -647,9 +919,13 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { |
| 647 | 919 | radius: 6.0, |
| 648 | 920 | pad: [6.0, 6.0], |
| 649 | 921 | anchor: Some(anchor), |
| 922 | role: Some(Role::Dialog), | |
| 650 | 923 | ..Spec::default() |
| 651 | 924 | }, |
| 652 | 925 | ); |
| 926 | if let Some(node) = ui.access(id) { | |
| 927 | node.set_label("Symbol"); | |
| 928 | } | |
| 653 | 929 | let mut picked = None; |
| 654 | 930 | for (row, shapes) in GALLERY.iter().enumerate() { |
| 655 | 931 | ui.open( |
| ... | ... | @@ -678,7 +954,7 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { |
| 678 | 954 | if shape == 0 && row == 0 && column > 0 { |
| 679 | 955 | continue; |
| 680 | 956 | } |
| 681 | let none = shape == 0 && row == 0; | |
| 957 | let none = shape == 0 && row == 0 && snowbound; | |
| 682 | 958 | let spec = if none { |
| 683 | 959 | Spec { |
| 684 | 960 | flags: Flags::CLICKABLE, |
| ... | ... | @@ -687,11 +963,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { |
| 687 | 963 | hover_fill: Some(theme.hover()), |
| 688 | 964 | radius: 3.0, |
| 689 | 965 | center: true, |
| 966 | role: Some(Role::Button), | |
| 690 | 967 | ..Spec::default() |
| 691 | 968 | } |
| 692 | 969 | } else if shape == 0 { |
| 693 | 970 | Spec { |
| 694 | size: [px(CELL), px(CELL)], | |
| 971 | size: [px(if row == 0 { CELL * 3.0 } else { CELL }), px(CELL)], | |
| 695 | 972 | ..Spec::default() |
| 696 | 973 | } |
| 697 | 974 | } else { |
| ... | ... | @@ -703,11 +980,12 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { |
| 703 | 980 | hover_fill: Some(theme.hover()), |
| 704 | 981 | radius: 3.0, |
| 705 | 982 | center: true, |
| 983 | role: Some(Role::Button), | |
| 706 | 984 | ..Spec::default() |
| 707 | 985 | } |
| 708 | 986 | }; |
| 709 | 987 | if ui.leaf(("cell", column), spec).clicked && (none || shape != 0) { |
| 710 | picked = Some(shape); | |
| 988 | picked = Some(Pick::Symbol(shape)); | |
| 711 | 989 | } |
| 712 | 990 | if let Some(name) = symbol_name(shape) { |
| 713 | 991 | ui::popup::tooltip(ui, name, "", None); |
| ... | ... | @@ -715,6 +993,64 @@ fn gallery(ui: &mut Ui, anchor: Anchor) -> Option<u16> { |
| 715 | 993 | } |
| 716 | 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 | 1054 | ui.close(); |
| 719 | 1055 | if picked.is_some() { |
| 720 | 1056 | ui.close_popup(id); |
| ... | ... | @@ -729,17 +1065,20 @@ fn swatch( |
| 729 | 1065 | name: &str, |
| 730 | 1066 | anchor: Anchor, |
| 731 | 1067 | none: &str, |
| 732 | swatches: &[u32], | |
| 1068 | swatches: &[(u32, &str)], | |
| 733 | 1069 | columns: usize, |
| 734 | 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 | 1075 | let chosen = ui::popup::colors(ui, popup(name), anchor, none, &colors, columns)?; |
| 737 | 1076 | Some(chosen.and_then(|chosen| { |
| 738 | 1077 | swatches |
| 739 | 1078 | .iter() |
| 740 | 1079 | .zip(&colors) |
| 741 | .find(|(_, color)| **color == chosen) | |
| 742 | .map(|(stored, _)| *stored) | |
| 1080 | .find(|(_, (color, _))| *color == chosen) | |
| 1081 | .map(|((stored, _), _)| *stored) | |
| 743 | 1082 | })) |
| 744 | 1083 | } |
| 745 | 1084 | |
| ... | ... | @@ -747,11 +1086,238 @@ fn swatch( |
| 747 | 1086 | mod tests { |
| 748 | 1087 | use super::*; |
| 749 | 1088 | |
| 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 | 1268 | /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but |
| 751 | 1269 | /// the follow-up flags it keeps for Outlook tasks. |
| 752 | 1270 | /// OneNote 2010's gallery, cell by cell: its page "Cells" tags a paragraph with a tag |
| 753 | 1271 | /// named for each cell's row and column, its symbol picked from that cell |
| 754 | 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 | 1321 | #[test] |
| 756 | 1322 | fn the_gallery_lays_symbols_out_as_onenotes_does() { |
| 757 | 1323 | use onestore::{ |
crates/snowbound/src/templates.rs+8-1| ... | ... | @@ -166,9 +166,11 @@ fn strip( |
| 166 | 166 | border: Some(theme.chip), |
| 167 | 167 | hover_border: Some(theme.accent), |
| 168 | 168 | radius: 4.0, |
| 169 | role: Some(accesskit::Role::Button), | |
| 169 | 170 | ..Spec::default() |
| 170 | 171 | }, |
| 171 | 172 | ); |
| 173 | crate::name(ui, ui.id((index, *name)), name); | |
| 172 | 174 | if swatch.clicked { |
| 173 | 175 | chosen = Some(Choice::Color(index)); |
| 174 | 176 | } |
| ... | ... | @@ -191,9 +193,11 @@ fn strip( |
| 191 | 193 | hover_fill: Some(theme.hover()), |
| 192 | 194 | radius: 11.0, |
| 193 | 195 | center: true, |
| 196 | role: Some(accesskit::Role::Button), | |
| 194 | 197 | ..Spec::default() |
| 195 | 198 | }, |
| 196 | 199 | ); |
| 200 | crate::name(ui, ui.id("dismiss"), "Close"); | |
| 197 | 201 | if close.clicked { |
| 198 | 202 | chosen = Some(Choice::Dismiss); |
| 199 | 203 | } |
| ... | ... | @@ -244,9 +248,10 @@ fn gallery( |
| 244 | 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 | 252 | chosen = Some(Choice::Dismiss); |
| 249 | 253 | } |
| 254 | crate::name(ui, ui.id("close"), "Close"); | |
| 250 | 255 | ui.close(); |
| 251 | 256 | ui.open( |
| 252 | 257 | "grid", |
| ... | ... | @@ -351,9 +356,11 @@ fn tile( |
| 351 | 356 | icon: (choice == Choice::More).then_some(art::PLUS), |
| 352 | 357 | color: Some(theme.text_dim), |
| 353 | 358 | center: true, |
| 359 | role: Some(accesskit::Role::Button), | |
| 354 | 360 | ..Spec::default() |
| 355 | 361 | }, |
| 356 | 362 | ); |
| 363 | crate::name(ui, id, label); | |
| 357 | 364 | match choice { |
| 358 | 365 | Choice::Template(name) => { |
| 359 | 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 | ||
| 7 | use crate::{State, UserEvent, platform}; | |
| 8 | use ring::signature::{ED25519, UnparsedPublicKey}; | |
| 9 | use serde::Deserialize; | |
| 10 | use std::collections::HashMap; | |
| 11 | use std::ffi::OsString; | |
| 12 | use std::path::{Path, PathBuf}; | |
| 13 | use std::process::Command; | |
| 14 | use std::sync::{Arc, Mutex}; | |
| 15 | use std::time::{Duration, Instant}; | |
| 16 | use winit::event_loop::EventLoopProxy; | |
| 17 | ||
| 18 | /// Where the builds are published. | |
| 19 | const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; | |
| 20 | ||
| 21 | /// The release key's public half, in hex. | |
| 22 | const KEY: &str = include_str!("../release-key.pub"); | |
| 23 | ||
| 24 | /// The argument `relaunch` starts the old executable with to finish an update. | |
| 25 | pub const FINISH: &str = "--finish-update"; | |
| 26 | ||
| 27 | const DAY: Duration = Duration::from_secs(24 * 60 * 60); | |
| 28 | ||
| 29 | /// This build's version as `tools/release.py` stamped it; development builds have none. | |
| 30 | fn 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. | |
| 35 | fn 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)] | |
| 46 | pub struct Version { | |
| 47 | date: String, | |
| 48 | revision: u32, | |
| 49 | } | |
| 50 | ||
| 51 | impl 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 | ||
| 81 | impl 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. | |
| 88 | pub 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)] | |
| 96 | struct Build { | |
| 97 | version: String, | |
| 98 | archives: HashMap<String, Archive>, | |
| 99 | } | |
| 100 | ||
| 101 | #[derive(Clone, Debug, Deserialize)] | |
| 102 | struct 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 | ||
| 110 | fn 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 | ||
| 123 | fn 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`. | |
| 131 | fn 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`. | |
| 149 | fn 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 | ||
| 169 | fn 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. | |
| 187 | fn 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. | |
| 201 | fn 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. | |
| 207 | fn 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. | |
| 218 | fn 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. | |
| 247 | fn 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)] | |
| 267 | pub 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 | ||
| 279 | const UNREACHABLE: &str = | |
| 280 | "Snowbound couldn’t reach the update server. Check your connection, then try again."; | |
| 281 | const 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. | |
| 285 | type 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. | |
| 288 | fn 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. | |
| 342 | fn 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)] | |
| 356 | struct 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. | |
| 366 | pub 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 | ||
| 373 | impl 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 | ||
| 466 | impl 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. | |
| 510 | pub 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. | |
| 516 | pub 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. | |
| 530 | pub 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)] | |
| 562 | mod 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 | 7 | /// Reports until dropped. |
| 8 | 8 | pub struct Watch { |
| 9 | 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 | } |
| 11 | 14 | |
| 12 | 15 | /// 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 | 38 | } else { |
| 36 | 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 | 48 | let folder = root.clone(); |
| 39 | 49 | let relative = move |paths: Vec<PathBuf>| { |
| 40 | 50 | changed( |
| ... | ... | @@ -50,7 +60,10 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) |
| 50 | 60 | platform::Stream::start(&folders, Box::new(relative)) |
| 51 | 61 | .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display())) |
| 52 | 62 | .ok() |
| 53 | .map(|stream| Watch { _stream: stream }) | |
| 63 | .map(|stream| Watch { | |
| 64 | _stream: stream, | |
| 65 | _presenter: presenter, | |
| 66 | }) | |
| 54 | 67 | } |
| 55 | 68 | |
| 56 | 69 | /// 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 | ||
| 4 | use std::path::{Path, PathBuf}; | |
| 5 | use std::process::Command; | |
| 6 | ||
| 7 | /// A scratch folder, deleted when dropped. | |
| 8 | struct Scratch(PathBuf); | |
| 9 | ||
| 10 | impl 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 | ||
| 19 | impl 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. | |
| 27 | fn 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. | |
| 71 | fn 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 | ||
| 81 | fn 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] | |
| 93 | fn 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] | |
| 110 | fn 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 | 5 | publish = false |
| 6 | 6 | |
| 7 | 7 | [dependencies] |
| 8 | accesskit = "0.25.0" | |
| 8 | 9 | draw = { path = "../draw", default-features = false, features = ["render"] } |
| 9 | 10 | nucleo-matcher = "0.3.1" |
| 10 | 11 | parley.workspace = true |
| 11 | 12 | winit = "0.30.13" |
| 12 | 13 | |
| 13 | 14 | [dev-dependencies] |
| 15 | accesskit_consumer = "0.39.0" | |
| 14 | 16 | draw = { path = "../draw", features = ["wgpu"] } |
| 15 | 17 | pollster = "0.4" |
| 16 | 18 | png = "0.18" |
crates/ui/examples/popups.rs+18-9| ... | ... | @@ -502,14 +502,23 @@ impl Scene { |
| 502 | 502 | }, |
| 503 | 503 | ); |
| 504 | 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 | 514 | let split = ui.id("color"); |
| 507 | 515 | shell::split_button( |
| 508 | 516 | ui, |
| 509 | 517 | "color", |
| 518 | "Font Color", | |
| 510 | 519 | art!("icons/font-color"), |
| 511 | 520 | Some(draw::srgb(0xe8, 0x3a, 0x30)), |
| 512 | false, | |
| 521 | None, | |
| 513 | 522 | Id::ROOT.child("font color"), |
| 514 | 523 | ); |
| 515 | 524 | ui.close(); |
| ... | ... | @@ -581,7 +590,7 @@ fn menu(scene: &mut Scene) { |
| 581 | 590 | text: "Bold", |
| 582 | 591 | icon: Some(art!("icons/bold")), |
| 583 | 592 | shortcut: "⌘B", |
| 584 | checked: true, | |
| 593 | checked: Some(true), | |
| 585 | 594 | separated: true, |
| 586 | 595 | ..Item::default() |
| 587 | 596 | }, |
| ... | ... | @@ -637,7 +646,7 @@ fn font_items() -> Vec<Item<'static>> { |
| 637 | 646 | .iter() |
| 638 | 647 | .map(|text| Item { |
| 639 | 648 | text, |
| 640 | checked: *text == "Calibri", | |
| 649 | checked: Some(*text == "Calibri"), | |
| 641 | 650 | ..Item::default() |
| 642 | 651 | }) |
| 643 | 652 | .collect() |
| ... | ... | @@ -652,7 +661,7 @@ fn fonts(scene: &mut Scene) { |
| 652 | 661 | }); |
| 653 | 662 | } |
| 654 | 663 | |
| 655 | fn office_colors() -> Vec<[f32; 4]> { | |
| 664 | fn office_colors() -> Vec<([f32; 4], &'static str)> { | |
| 656 | 665 | let theme = [ |
| 657 | 666 | (0xff, 0xff, 0xff), |
| 658 | 667 | (0x00, 0x00, 0x00), |
| ... | ... | @@ -691,7 +700,7 @@ fn office_colors() -> Vec<[f32; 4]> { |
| 691 | 700 | })); |
| 692 | 701 | } |
| 693 | 702 | colors.extend(standard); |
| 694 | colors | |
| 703 | colors.into_iter().map(|color| (color, "")).collect() | |
| 695 | 704 | } |
| 696 | 705 | |
| 697 | 706 | fn colors(scene: &mut Scene) { |
| ... | ... | @@ -731,7 +740,7 @@ fn highlight(scene: &mut Scene) { |
| 731 | 740 | (0xc0, 0xc0, 0xc0), |
| 732 | 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 | 744 | let id = Id::ROOT.child("highlight"); |
| 736 | 745 | scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| { |
| 737 | 746 | let anchor = below(ui, split); |
| ... | ... | @@ -761,7 +770,7 @@ fn palette(scene: &mut Scene) { |
| 761 | 770 | }); |
| 762 | 771 | let id = Id::ROOT.child("palette"); |
| 763 | 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 | } |
| 767 | 776 | |
| ... | ... | @@ -806,7 +815,7 @@ fn tags(scene: &mut Scene) { |
| 806 | 815 | .map(|(text, icon, shortcut)| Item { |
| 807 | 816 | text, |
| 808 | 817 | icon, |
| 809 | colored: true, | |
| 818 | tint: Some([1.0; 4]), | |
| 810 | 819 | shortcut, |
| 811 | 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 | ||
| 5 | use crate::{Axis, Event, Flags, Id, Ui}; | |
| 6 | use accesskit::{ | |
| 7 | Action, ActionData, ActionRequest, Node, NodeId, Orientation, Rect, Role, TreeId, TreeInfo, | |
| 8 | TreeUpdate, | |
| 9 | }; | |
| 10 | use std::collections::{HashMap, HashSet}; | |
| 11 | use winit::{event::Ime, keyboard::NamedKey}; | |
| 12 | ||
| 13 | /// A control the keyboard can focus. | |
| 14 | pub(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 | ||
| 26 | impl 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. | |
| 34 | pub(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. | |
| 59 | fn 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. | |
| 72 | fn 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. | |
| 86 | fn 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. | |
| 91 | fn 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 | ||
| 96 | fn 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 | ||
| 101 | impl 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)] | |
| 499 | mod tests; |
crates/ui/src/access/tests.rs created+750| ... | ... | @@ -0,0 +1,750 @@ |
| 1 | use crate::{shell, *}; | |
| 2 | use accesskit::{Action, ActionRequest, NodeId, Role, TreeId}; | |
| 3 | use accesskit_consumer::{NodeRef, Tree, common_filter}; | |
| 4 | use std::time::Duration; | |
| 5 | use winit::keyboard::NamedKey; | |
| 6 | ||
| 7 | thread_local! { | |
| 8 | static START: Instant = Instant::now(); | |
| 9 | static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) }; | |
| 10 | } | |
| 11 | ||
| 12 | fn 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. | |
| 17 | fn 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. | |
| 30 | fn 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. | |
| 89 | fn tree(ui: &mut Ui) -> Tree { | |
| 90 | ui.deactivate_accessibility(); | |
| 91 | Tree::new(ui.accessibility("Notes", 2.0), true) | |
| 92 | } | |
| 93 | ||
| 94 | fn key(ui: &mut Ui, named: NamedKey) { | |
| 95 | ui.event(Event::Key { | |
| 96 | key: winit::keyboard::Key::Named(named), | |
| 97 | text: None, | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | fn toolbar_id() -> Id { | |
| 102 | Id::ROOT.child("toolbar") | |
| 103 | } | |
| 104 | ||
| 105 | fn font_menu() -> Id { | |
| 106 | Id::ROOT.child("font menu") | |
| 107 | } | |
| 108 | ||
| 109 | fn 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. | |
| 114 | fn 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] | |
| 203 | fn 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] | |
| 226 | fn 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] | |
| 251 | fn 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] | |
| 282 | fn 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] | |
| 301 | fn 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] | |
| 344 | fn 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] | |
| 360 | fn 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] | |
| 422 | fn 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 | ||
| 485 | fn 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. | |
| 490 | fn 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] | |
| 543 | fn 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] | |
| 603 | fn 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] | |
| 641 | fn 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] | |
| 690 | fn 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 | ||
| 743 | struct NoChanges; | |
| 744 | ||
| 745 | impl 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 | 3 | //! animation state. Input is answered with the previous frame's layout, so a frame's |
| 4 | 4 | //! events are routed before building and its layout is solved after. |
| 5 | 5 | |
| 6 | mod access; | |
| 6 | 7 | mod layout; |
| 7 | 8 | mod list; |
| 8 | 9 | pub mod popup; |
| ... | ... | @@ -15,7 +16,7 @@ pub use list::{List, Row, Rows, list}; |
| 15 | 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; |
| 16 | 17 | pub use widgets::{ |
| 17 | 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 | }; |
| 20 | 21 | |
| 21 | 22 | use draw::{ |
| ... | ... | @@ -55,6 +56,8 @@ const POPUP: [f32; 2] = [0.16, 0.12]; |
| 55 | 56 | const TIP_DELAY: f32 = 0.5; |
| 56 | 57 | const TIP_WARM: f32 = 0.5; |
| 57 | 58 | const TIP_FADE: f32 = 0.1; |
| 59 | /// How far outside a control the ring the keyboard's focus draws round it lies, and its width. | |
| 60 | const FOCUS_RING: f32 = 2.0; | |
| 58 | 61 | |
| 59 | 62 | /// A box's identity across frames: its parent's id combined with a builder-chosen part. |
| 60 | 63 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| ... | ... | @@ -69,6 +72,11 @@ impl Id { |
| 69 | 72 | part.hash(&mut hasher); |
| 70 | 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 | } |
| 73 | 81 | |
| 74 | 82 | #[derive(Clone, Copy, Debug, PartialEq)] |
| ... | ... | @@ -210,10 +218,11 @@ pub enum Anchor { |
| 210 | 218 | Over([f32; 4]), |
| 211 | 219 | /// At a point, as a context menu opens. |
| 212 | 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 | 223 | Dialog, |
| 215 | /// Centred across the window near its top, swinging in as a dialog does, as a command | |
| 216 | /// palette opens. | |
| 224 | /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a | |
| 225 | /// command palette opens. | |
| 217 | 226 | Top, |
| 218 | 227 | } |
| 219 | 228 | |
| ... | ... | @@ -227,9 +236,8 @@ impl Anchor { |
| 227 | 236 | Anchor::Right(rect) => (rect, Some(0)), |
| 228 | 237 | Anchor::Over(rect) => (rect, None), |
| 229 | 238 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), |
| 230 | Anchor::Dialog => return ((room - size) / 2.0).max(0.0), | |
| 231 | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0), | |
| 232 | Anchor::Top => return room / 8.0, | |
| 239 | Anchor::Dialog | 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), | |
| 233 | 241 | }; |
| 234 | 242 | let [low, high] = [rect[axis], rect[axis + 2]]; |
| 235 | 243 | let (first, second) = if along == Some(axis) { |
| ... | ... | @@ -363,12 +371,17 @@ pub struct Spec<'a> { |
| 363 | 371 | /// How far the children fade out into the box's fill towards its leading and trailing |
| 364 | 372 | /// edges, as a row of them cut there does. |
| 365 | 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 | } |
| 367 | 378 | |
| 368 | 379 | /// Input the host forwards; positions and wheel distances are logical pixels. |
| 369 | 380 | #[derive(Clone, Debug, PartialEq)] |
| 370 | 381 | pub enum Event { |
| 371 | 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 | 385 | PointerLeft, |
| 373 | 386 | Button { |
| 374 | 387 | button: MouseButton, |
| ... | ... | @@ -382,6 +395,8 @@ pub enum Event { |
| 382 | 395 | }, |
| 383 | 396 | Ime(Ime), |
| 384 | 397 | Modifiers(ModifiersState), |
| 398 | /// Assistive technology acting on a box of the tree `Ui::accessibility` built. | |
| 399 | Access(accesskit::ActionRequest), | |
| 385 | 400 | } |
| 386 | 401 | |
| 387 | 402 | /// How the user acted on a box this frame. |
| ... | ... | @@ -501,6 +516,7 @@ struct Built { |
| 501 | 516 | marks: Vec<([f32; 4], [f32; 4], f32)>, |
| 502 | 517 | fold: Option<u32>, |
| 503 | 518 | fade: [f32; 2], |
| 519 | access: Option<accesskit::Node>, | |
| 504 | 520 | /// Folded away with the form it belongs to: it takes no room, paint or input, and takes |
| 505 | 521 | /// its group's rectangle. |
| 506 | 522 | hidden: bool, |
| ... | ... | @@ -524,8 +540,8 @@ struct State { |
| 524 | 540 | /// A text field's selection, and the selection and unit of the press a drag extends. |
| 525 | 541 | selection: Selection, |
| 526 | 542 | press: (Selection, SelectionUnit), |
| 527 | /// The field selects all its text when next built. | |
| 528 | select_all: bool, | |
| 543 | /// The text a field selects when next built, from and to byte offsets clamped to its end. | |
| 544 | select: Option<[usize; 2]>, | |
| 529 | 545 | /// Where a dragged scrollbar thumb was taken, from its start. |
| 530 | 546 | grab: f32, |
| 531 | 547 | /// A system scroller's part held down, and when a held arrow or track next repeats. |
| ... | ... | @@ -544,9 +560,11 @@ struct Popup { |
| 544 | 560 | highlight: Option<u64>, |
| 545 | 561 | /// A colour picker's hue in degrees, saturation and lightness, once it has one. |
| 546 | 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 | 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 | } |
| 551 | 569 | |
| 552 | 570 | /// 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 | 702 | caret: Option<(Id, Instant, u64)>, |
| 685 | 703 | /// When the next timed change is due, such as a caret's blink. |
| 686 | 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 | } |
| 688 | 714 | |
| 689 | 715 | impl Ui { |
| ... | ... | @@ -726,6 +752,10 @@ impl Ui { |
| 726 | 752 | routed: false, |
| 727 | 753 | caret: None, |
| 728 | 754 | wake: None, |
| 755 | stops: Vec::new(), | |
| 756 | focus_ring: false, | |
| 757 | resume: None, | |
| 758 | sent: HashMap::new(), | |
| 729 | 759 | } |
| 730 | 760 | } |
| 731 | 761 | |
| ... | ... | @@ -767,6 +797,14 @@ impl Ui { |
| 767 | 797 | self.focus |
| 768 | 798 | } |
| 769 | 799 | |
| 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 | 808 | pub fn set_focus(&mut self, id: Option<Id>) { |
| 771 | 809 | self.focus = id; |
| 772 | 810 | } |
| ... | ... | @@ -776,7 +814,7 @@ impl Ui { |
| 776 | 814 | pub fn focus_all(&mut self, id: Id) { |
| 777 | 815 | let state = self.states.entry(id).or_default(); |
| 778 | 816 | state.touched = self.frame; |
| 779 | state.select_all = true; | |
| 817 | state.select = Some([0, usize::MAX]); | |
| 780 | 818 | self.focus = Some(id); |
| 781 | 819 | } |
| 782 | 820 | |
| ... | ... | @@ -833,6 +871,18 @@ impl Ui { |
| 833 | 871 | for event in std::mem::take(&mut self.queue) { |
| 834 | 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 | 886 | self.ease(dt); |
| 837 | 887 | } |
| 838 | 888 | |
| ... | ... | @@ -856,6 +906,7 @@ impl Ui { |
| 856 | 906 | self.hover = hover; |
| 857 | 907 | self.custom_event(self.active.or(hover), event); |
| 858 | 908 | } |
| 909 | Event::Pressure(_) => self.custom_event(self.active.or(self.hover), event), | |
| 859 | 910 | Event::PointerLeft => { |
| 860 | 911 | self.pointer = None; |
| 861 | 912 | if self.active.is_none() { |
| ... | ... | @@ -871,6 +922,7 @@ impl Ui { |
| 871 | 922 | let Some(point) = self.pointer else { |
| 872 | 923 | return; |
| 873 | 924 | }; |
| 925 | self.focus_ring = false; | |
| 874 | 926 | if !self.popups.is_empty() { |
| 875 | 927 | let under = self.popups.iter().rposition(|popup| { |
| 876 | 928 | self.rect(popup.id) |
| ... | ... | @@ -945,6 +997,11 @@ impl Ui { |
| 945 | 997 | key: Key::Named(NamedKey::Escape), |
| 946 | 998 | .. |
| 947 | 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 | 1005 | Event::Key { .. } | Event::Ime(_) => { |
| 949 | 1006 | if let Some(focus) = self.focus { |
| 950 | 1007 | self.signals.entry(focus).or_default().events.push(event); |
| ... | ... | @@ -1008,15 +1065,32 @@ impl Ui { |
| 1008 | 1065 | self.popups.iter().position(|popup| popup.id == id) |
| 1009 | 1066 | }) |
| 1010 | 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 | 1085 | self.popups.push(Popup { |
| 1013 | 1086 | id, |
| 1014 | 1087 | focus: self.focus, |
| 1015 | 1088 | query: String::new(), |
| 1016 | 1089 | highlight: None, |
| 1017 | 1090 | picked: None, |
| 1018 | height: None, | |
| 1019 | 1091 | opened: self.now, |
| 1092 | beside, | |
| 1093 | submenu: None, | |
| 1020 | 1094 | }); |
| 1021 | 1095 | self.focus = Some(id); |
| 1022 | 1096 | self.states.entry(id).or_default().touched = self.frame; |
| ... | ... | @@ -1026,9 +1100,13 @@ impl Ui { |
| 1026 | 1100 | self.popups.iter().any(|popup| popup.id == id) |
| 1027 | 1101 | } |
| 1028 | 1102 | |
| 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 | 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 | 1110 | self.close_from(index); |
| 1033 | 1111 | } |
| 1034 | 1112 | } |
| ... | ... | @@ -1071,7 +1149,7 @@ impl Ui { |
| 1071 | 1149 | } |
| 1072 | 1150 | |
| 1073 | 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 | 1153 | self.nodes[*self.stack.last().unwrap()].id |
| 1076 | 1154 | } |
| 1077 | 1155 | |
| ... | ... | @@ -1147,6 +1225,12 @@ impl Ui { |
| 1147 | 1225 | *value |
| 1148 | 1226 | } |
| 1149 | 1227 | |
| 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 | 1234 | /// Previews `family` in the font `data` holds, as a font menu shows the substitute a |
| 1151 | 1235 | /// missing family lays out in. |
| 1152 | 1236 | pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) { |
| ... | ... | @@ -1339,11 +1423,17 @@ impl Ui { |
| 1339 | 1423 | self.paint(index, None, None); |
| 1340 | 1424 | } |
| 1341 | 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 | 1432 | if id.is_some_and(|id| !self.states.contains_key(&id)) { |
| 1344 | 1433 | *id = None; |
| 1345 | 1434 | } |
| 1346 | 1435 | } |
| 1436 | self.stops = self.stops(); | |
| 1347 | 1437 | } |
| 1348 | 1438 | |
| 1349 | 1439 | /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open. |
| ... | ... | @@ -1380,7 +1470,7 @@ impl Ui { |
| 1380 | 1470 | if anchor == Anchor::Dialog { |
| 1381 | 1471 | self.display.push(Display::Rect { |
| 1382 | 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 | 1474 | shade: None, |
| 1385 | 1475 | border: None, |
| 1386 | 1476 | radius: 0.0, |
| ... | ... | @@ -1614,6 +1704,21 @@ impl Ui { |
| 1614 | 1704 | if inner_clip != clip { |
| 1615 | 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 | 1722 | if own.is_some() { |
| 1618 | 1723 | self.display.push(Display::Motion(motion)); |
| 1619 | 1724 | } |
| ... | ... | @@ -1812,13 +1917,13 @@ impl Ui { |
| 1812 | 1917 | pub(crate) fn field( |
| 1813 | 1918 | &mut self, |
| 1814 | 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 | 1925 | let state = self.states.entry(id).or_default(); |
| 1817 | ( | |
| 1818 | &mut state.selection, | |
| 1819 | &mut state.press, | |
| 1820 | &mut state.select_all, | |
| 1821 | ) | |
| 1926 | (&mut state.selection, &mut state.press, &mut state.select) | |
| 1822 | 1927 | } |
| 1823 | 1928 | |
| 1824 | 1929 | pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { |
| ... | ... | @@ -1881,6 +1986,7 @@ impl Built { |
| 1881 | 1986 | marks: Vec::new(), |
| 1882 | 1987 | fold: spec.fold, |
| 1883 | 1988 | fade: spec.fade, |
| 1989 | access: spec.role.map(accesskit::Node::new), | |
| 1884 | 1990 | hidden: false, |
| 1885 | 1991 | computed: [0.0; 2], |
| 1886 | 1992 | relative: [0.0; 2], |
crates/ui/src/popup.rs+284-69| ... | ... | @@ -6,6 +6,7 @@ use crate::{ |
| 6 | 6 | Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec, |
| 7 | 7 | TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field, |
| 8 | 8 | }; |
| 9 | use accesskit::Role; | |
| 9 | 10 | use nucleo_matcher::{ |
| 10 | 11 | Config, Matcher, Utf32Str, |
| 11 | 12 | pattern::{AtomKind, CaseMatching, Normalization, Pattern}, |
| ... | ... | @@ -21,6 +22,8 @@ const ROW: f32 = 26.0; |
| 21 | 22 | pub(crate) const MENU_ROW: f32 = 22.0; |
| 22 | 23 | /// Rows a list shows before it scrolls. |
| 23 | 24 | const ROWS: f32 = 12.0; |
| 25 | /// How long the pointer rests on a row before its submenu opens, as Windows waits by default. | |
| 26 | const SUBMENU_DELAY: Duration = Duration::from_millis(200); | |
| 24 | 27 | const NARROWEST: f32 = 140.0; |
| 25 | 28 | const PALETTE: f32 = 560.0; |
| 26 | 29 | /// Where a tooltip's description wraps. |
| ... | ... | @@ -29,8 +32,6 @@ const TIP_WIDTH: f32 = 280.0; |
| 29 | 32 | const CELL: f32 = 22.0; |
| 30 | 33 | /// Inset of an item's text from the highlight it previews. |
| 31 | 34 | const SAMPLE_PAD: f32 = 2.0; |
| 32 | /// Seconds a popup takes to ease to the height of its results. | |
| 33 | const RESIZE: f32 = 0.15; | |
| 34 | 35 | thread_local! { |
| 35 | 36 | /// Scratch space for ranking, a few hundred kilobytes, reused across frames. |
| 36 | 37 | static MATCHER: RefCell<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT)); |
| ... | ... | @@ -57,13 +58,17 @@ pub struct Item<'a> { |
| 57 | 58 | pub ink: Option<[f32; 4]>, |
| 58 | 59 | pub highlight: Option<[f32; 4]>, |
| 59 | 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 | pub colored: bool, | |
| 61 | /// What the icon's `currentColor` paints in place of the text's colour, as a section's | |
| 62 | /// icon takes its colour; white leaves an icon with colours of its own as drawn. | |
| 63 | pub tint: Option<[f32; 4]>, | |
| 62 | 64 | /// Keys that run the item, shown dim at the trailing edge. |
| 63 | 65 | pub shortcut: &'a str, |
| 64 | /// Marked with a check in place of its icon. | |
| 65 | pub checked: bool, | |
| 66 | /// Starts highlighted when the menu opens, as a checked item does. | |
| 66 | /// Whether a check marks it in place of its icon; `None` where it is not a toggle. | |
| 67 | pub checked: Option<bool>, | |
| 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 | 72 | pub current: bool, |
| 68 | 73 | pub disabled: bool, |
| 69 | 74 | /// Starts a group, ruled off from the one above while unfiltered. |
| ... | ... | @@ -72,6 +77,13 @@ pub struct Item<'a> { |
| 72 | 77 | pub heading: bool, |
| 73 | 78 | } |
| 74 | 79 | |
| 80 | impl 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 | 87 | /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a |
| 76 | 88 | /// filter field showing `filter` while empty when given. Returns the index of the item |
| 77 | 89 | /// chosen. |
| ... | ... | @@ -101,10 +113,12 @@ pub fn menu( |
| 101 | 113 | 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 | 122 | let width = |
| 109 | 123 | text + if shortcut > 0.0 { |
| 110 | 124 | 3.0 * ICON_GAP + shortcut |
| ... | ... | @@ -125,18 +139,73 @@ pub fn menu( |
| 125 | 139 | choose( |
| 126 | 140 | ui, |
| 127 | 141 | id, |
| 142 | Role::Menu, | |
| 128 | 143 | anchor, |
| 129 | items, | |
| 144 | &[("", items)], | |
| 130 | 145 | filter, |
| 131 | 146 | width.max(least).max(NARROWEST), |
| 132 | 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. | |
| 155 | pub 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 | } |
| 135 | 203 | |
| 136 | 204 | /// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip |
| 137 | 205 | /// below the box built last while the pointer rests on it or on a box inside it: after a |
| 138 | 206 | /// 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. | |
| 140 | 209 | pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) { |
| 141 | 210 | tooltip_below(ui, None, title, keys, description); |
| 142 | 211 | } |
| ... | ... | @@ -157,6 +226,27 @@ fn tooltip_below( |
| 157 | 226 | let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else { |
| 158 | 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 | 250 | // The boxes built since are the box's own. |
| 161 | 251 | let hovered = ui |
| 162 | 252 | .hover |
| ... | ... | @@ -238,31 +328,62 @@ fn tooltip_below( |
| 238 | 328 | } |
| 239 | 329 | |
| 240 | 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 field | |
| 242 | /// showing `placeholder` while empty. Returns the index of the item chosen. | |
| 243 | pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option<usize> { | |
| 331 | /// under a filter field showing `placeholder` while empty. What is typed picks the first of | |
| 332 | /// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most | |
| 333 | /// relevant first. Returns the mode and the index of the item chosen. | |
| 334 | pub fn palette( | |
| 335 | ui: &mut Ui, | |
| 336 | id: Id, | |
| 337 | modes: &[(&str, &[Item])], | |
| 338 | placeholder: &str, | |
| 339 | ) -> Option<(usize, usize)> { | |
| 244 | 340 | if !ui.popup_open(id) { |
| 245 | 341 | return None; |
| 246 | 342 | } |
| 247 | 343 | let window = ui.rect(Id::ROOT).unwrap_or_default(); |
| 248 | 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 | } |
| 251 | 356 | |
| 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. | |
| 359 | pub 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)] | |
| 252 | 371 | fn choose( |
| 253 | 372 | ui: &mut Ui, |
| 254 | 373 | id: Id, |
| 374 | role: Role, | |
| 255 | 375 | anchor: Anchor, |
| 256 | items: &[Item], | |
| 376 | modes: &[(&str, &[Item])], | |
| 257 | 377 | filter: Option<&str>, |
| 258 | 378 | width: f32, |
| 259 | 379 | row: f32, |
| 260 | ) -> Option<usize> { | |
| 380 | ) -> Option<(usize, usize)> { | |
| 261 | 381 | let field = id.child("filter"); |
| 262 | 382 | if filter.is_some() && ui.focus == Some(id) { |
| 263 | 383 | ui.focus = Some(field); |
| 264 | 384 | } |
| 265 | 385 | let keys = navigation(ui, &[id, field], &KEYS); |
| 386 | let rows = id.child("rows"); | |
| 266 | 387 | let popup = state(ui, id); |
| 267 | 388 | let mut query = std::mem::take(&mut popup.query); |
| 268 | 389 | let mut highlight = popup.highlight; |
| ... | ... | @@ -273,7 +394,7 @@ fn choose( |
| 273 | 394 | Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]), |
| 274 | 395 | _ => (Flags::default(), ROW), |
| 275 | 396 | }; |
| 276 | surface(ui, id, anchor, width); | |
| 397 | surface(ui, id, role, anchor, width); | |
| 277 | 398 | if let Some(placeholder) = filter { |
| 278 | 399 | let before = query.clone(); |
| 279 | 400 | text_field( |
| ... | ... | @@ -288,21 +409,32 @@ fn choose( |
| 288 | 409 | border: Some(theme.accent), |
| 289 | 410 | radius: 4.0, |
| 290 | 411 | pad: [8.0, 0.0], |
| 412 | role: Some(Role::SearchInput), | |
| 291 | 413 | ..Spec::default() |
| 292 | 414 | }, |
| 293 | 415 | ); |
| 416 | // New results show from their top at once. | |
| 294 | 417 | if query != before { |
| 295 | 418 | highlight = None; |
| 419 | ui.lists.remove(&rows); | |
| 296 | 420 | } |
| 297 | 421 | } |
| 298 | let matches = Matches::new(items, &query, style.rule_band); | |
| 299 | // Unfiltered, the current or checked item starts highlighted; filtered, the best match. | |
| 422 | let (mode, (prefix, items)) = modes | |
| 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 | 435 | highlight = highlight.or_else(|| { |
| 301 | let first = if query.is_empty() { | |
| 302 | matches | |
| 303 | .order | |
| 304 | .iter() | |
| 305 | .position(|index| items[*index].current || items[*index].checked) | |
| 436 | let first = if typed.is_empty() { | |
| 437 | matches.order.iter().position(|index| items[*index].current) | |
| 306 | 438 | } else { |
| 307 | 439 | (0..matches.count()).find(|row| matches.selectable(*row)) |
| 308 | 440 | }; |
| ... | ... | @@ -318,24 +450,6 @@ fn choose( |
| 318 | 450 | let view = content |
| 319 | 451 | .max(row) |
| 320 | 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 | 453 | let chosen = if matches.count() == 0 { |
| 340 | 454 | ui.leaf( |
| 341 | 455 | "empty", |
| ... | ... | @@ -358,16 +472,22 @@ fn choose( |
| 358 | 472 | }; |
| 359 | 473 | let clicked = crate::list( |
| 360 | 474 | ui, |
| 361 | id.child("rows"), | |
| 475 | rows, | |
| 362 | 476 | Spec { |
| 363 | 477 | size: [fill(), px(view)], |
| 364 | 478 | fill: Some(style.fill), |
| 479 | role: (role == Role::Dialog).then_some(Role::ListBox), | |
| 365 | 480 | ..Spec::default() |
| 366 | 481 | }, |
| 367 | 482 | list, |
| 368 | 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 | 491 | let entered = keys.contains(&NamedKey::Enter); |
| 372 | 492 | clicked.or_else(|| { |
| 373 | 493 | highlight |
| ... | ... | @@ -376,10 +496,14 @@ fn choose( |
| 376 | 496 | }) |
| 377 | 497 | }; |
| 378 | 498 | ui.close(); |
| 379 | ui.close(); | |
| 380 | 499 | if let Some(row) = chosen { |
| 381 | ui.close_popup(id); | |
| 382 | return Some(matches.order[row]); | |
| 500 | let index = 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 | 508 | let popup = state(ui, id); |
| 385 | 509 | popup.query = query; |
| ... | ... | @@ -388,9 +512,39 @@ fn choose( |
| 388 | 512 | } |
| 389 | 513 | |
| 390 | 514 | /// 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. | |
| 392 | fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { | |
| 515 | /// highlight when selected, under a rule when it starts a group. In a dialog's list it is | |
| 516 | /// an option. | |
| 517 | fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row) { | |
| 393 | 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 | 548 | if row.index > 0 && matches.ruled && item.separated { |
| 395 | 549 | ui.leaf( |
| 396 | 550 | "rule", |
| ... | ... | @@ -424,16 +578,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { |
| 424 | 578 | }, |
| 425 | 579 | ); |
| 426 | 580 | if matches.icons { |
| 427 | let tint = if item.colored && !item.checked { | |
| 428 | [1.0, 1.0, 1.0, color[3]] | |
| 429 | } else { | |
| 430 | color | |
| 581 | let checked = item.checked == Some(true); | |
| 582 | let tint = match item.tint { | |
| 583 | Some([red, green, blue, _]) if !checked => [red, green, blue, color[3]], | |
| 584 | _ => color, | |
| 431 | 585 | }; |
| 432 | 586 | ui.leaf( |
| 433 | 587 | "icon", |
| 434 | 588 | Spec { |
| 435 | 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 | 591 | color: Some(if item.disabled { |
| 438 | 592 | mix(tint, style.fill, 0.5) |
| 439 | 593 | } else { |
| ... | ... | @@ -486,12 +640,12 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { |
| 486 | 640 | ui.leaf("text", text); |
| 487 | 641 | } |
| 488 | 642 | } |
| 489 | if !item.shortcut.is_empty() { | |
| 643 | if !item.trailing().is_empty() { | |
| 490 | 644 | ui.leaf( |
| 491 | 645 | "shortcut", |
| 492 | 646 | Spec { |
| 493 | 647 | size: [fit(), fill()], |
| 494 | text: Some(item.shortcut), | |
| 648 | text: Some(item.trailing()), | |
| 495 | 649 | font_size: Some(style.font_size), |
| 496 | 650 | color: Some(style.dim), |
| 497 | 651 | ..Spec::default() |
| ... | ... | @@ -501,15 +655,16 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { |
| 501 | 655 | ui.close(); |
| 502 | 656 | } |
| 503 | 657 | |
| 504 | /// Builds popup `id` as a grid of `swatches` in rows of `columns` beside `anchor` while it | |
| 505 | /// is open, under a button labelled `none` for no colour of its own. Returns the swatch | |
| 506 | /// chosen, or None for the button. | |
| 658 | /// Builds popup `id` as a grid of `swatches`, each a colour and its name or `""` to be | |
| 659 | /// named by its hex, in rows of `columns` beside `anchor` while it is open, under a button | |
| 660 | /// labelled `none` for no colour of its own. Returns the swatch chosen, or None for the | |
| 661 | /// button. | |
| 507 | 662 | pub fn colors( |
| 508 | 663 | ui: &mut Ui, |
| 509 | 664 | id: Id, |
| 510 | 665 | anchor: Anchor, |
| 511 | 666 | none: &str, |
| 512 | swatches: &[[f32; 4]], | |
| 667 | swatches: &[([f32; 4], &str)], | |
| 513 | 668 | columns: usize, |
| 514 | 669 | ) -> Option<Option<[f32; 4]>> { |
| 515 | 670 | if !ui.popup_open(id) { |
| ... | ... | @@ -557,7 +712,7 @@ pub fn colors( |
| 557 | 712 | } |
| 558 | 713 | if let Some(index) = chosen { |
| 559 | 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 | } |
| 562 | 717 | |
| 563 | 718 | let theme = ui.theme.clone(); |
| ... | ... | @@ -565,6 +720,7 @@ pub fn colors( |
| 565 | 720 | surface( |
| 566 | 721 | ui, |
| 567 | 722 | id, |
| 723 | Role::Menu, | |
| 568 | 724 | anchor, |
| 569 | 725 | columns as f32 * CELL + 2.0 * ui.theme.menu().pad, |
| 570 | 726 | ); |
| ... | ... | @@ -577,6 +733,7 @@ pub fn colors( |
| 577 | 733 | fill: lit(0), |
| 578 | 734 | radius: 4.0, |
| 579 | 735 | pad: [8.0, 0.0], |
| 736 | role: Some(Role::MenuItem), | |
| 580 | 737 | ..Spec::default() |
| 581 | 738 | }, |
| 582 | 739 | ); |
| ... | ... | @@ -589,7 +746,7 @@ pub fn colors( |
| 589 | 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 | 750 | let index = 1 + row * columns + column; |
| 594 | 751 | ui.open_as( |
| 595 | 752 | cell(index), |
| ... | ... | @@ -599,9 +756,18 @@ pub fn colors( |
| 599 | 756 | fill: lit(index), |
| 600 | 757 | radius: 4.0, |
| 601 | 758 | pad: [3.0, 3.0], |
| 759 | role: Some(Role::MenuItem), | |
| 602 | 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 | 771 | ui.leaf( |
| 606 | 772 | "swatch", |
| 607 | 773 | Spec { |
| ... | ... | @@ -616,11 +782,19 @@ pub fn colors( |
| 616 | 782 | } |
| 617 | 783 | ui.close(); |
| 618 | 784 | } |
| 785 | highlighted(ui, id, highlight.map(cell)); | |
| 619 | 786 | ui.close(); |
| 620 | 787 | state(ui, id).highlight = highlight.map(|cell| cell as u64); |
| 621 | 788 | None |
| 622 | 789 | } |
| 623 | 790 | |
| 791 | /// Tells assistive technology the keys of `id` highlight `cell`, as a focus within it. | |
| 792 | fn 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 | 798 | /// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, |
| 625 | 799 | /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the |
| 626 | 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 | 851 | surface( |
| 678 | 852 | ui, |
| 679 | 853 | id, |
| 854 | Role::Menu, | |
| 680 | 855 | anchor, |
| 681 | 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 | 893 | flags: Flags::CLICKABLE, |
| 719 | 894 | size: [px(CELL), px(CELL)], |
| 720 | 895 | pad: [3.0, 3.0], |
| 896 | role: Some(Role::MenuItem), | |
| 721 | 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 | 903 | ui.leaf( |
| 725 | 904 | "square", |
| 726 | 905 | Spec { |
| ... | ... | @@ -740,6 +919,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 740 | 919 | ui.close(); |
| 741 | 920 | } |
| 742 | 921 | ui.close(); |
| 922 | highlighted(ui, id, highlight.map(cell)); | |
| 743 | 923 | ui.close(); |
| 744 | 924 | state(ui, id).highlight = highlight.map(|cell| cell as u64); |
| 745 | 925 | None |
| ... | ... | @@ -825,7 +1005,13 @@ pub fn gallery( |
| 825 | 1005 | .iter() |
| 826 | 1006 | .map(|group| group.columns as f32 * group.size[0]) |
| 827 | 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 | 1015 | let mut index = 0; |
| 830 | 1016 | for (number, group) in groups.iter().enumerate() { |
| 831 | 1017 | ui.leaf( |
| ... | ... | @@ -859,9 +1045,15 @@ pub fn gallery( |
| 859 | 1045 | border: current.contains(&index).then_some(theme.accent), |
| 860 | 1046 | radius: 4.0, |
| 861 | 1047 | pad: [PAD, PAD], |
| 1048 | role: Some(Role::MenuItem), | |
| 862 | 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 | 1057 | cell(ui, index); |
| 866 | 1058 | ui.close(); |
| 867 | 1059 | index += 1; |
| ... | ... | @@ -869,6 +1061,7 @@ pub fn gallery( |
| 869 | 1061 | ui.close(); |
| 870 | 1062 | } |
| 871 | 1063 | } |
| 1064 | highlighted(ui, id, highlight.map(cell_id)); | |
| 872 | 1065 | ui.close(); |
| 873 | 1066 | state(ui, id).highlight = highlight.map(|cell| cell as u64); |
| 874 | 1067 | None |
| ... | ... | @@ -931,7 +1124,16 @@ pub fn color_picker( |
| 931 | 1124 | } |
| 932 | 1125 | |
| 933 | 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 | 1137 | let color = |hsl| { |
| 936 | 1138 | let [red, green, blue] = from_hsl(hsl); |
| 937 | 1139 | draw::srgb(red, green, blue) |
| ... | ... | @@ -1095,11 +1297,21 @@ fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] { |
| 1095 | 1297 | [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8) |
| 1096 | 1298 | } |
| 1097 | 1299 | |
| 1098 | /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. | |
| 1099 | fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { | |
| 1300 | /// Opens popup `id`'s panel, a `role` `width` wide beside `anchor`; the caller closes it. | |
| 1301 | fn surface(ui: &mut Ui, id: Id, role: Role, anchor: Anchor, width: f32) { | |
| 1100 | 1302 | let style = ui.theme.menu(); |
| 1101 | 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 | 1315 | Anchor::Below([left, top, right, bottom]) => { |
| 1104 | 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 | 1334 | pad: [pad; 2], |
| 1123 | 1335 | gap: pad, |
| 1124 | 1336 | anchor: Some(anchor), |
| 1337 | role: Some(role), | |
| 1125 | 1338 | ..Spec::default() |
| 1126 | 1339 | }; |
| 1127 | 1340 | ui.open_as(id, spec); |
| ... | ... | @@ -1225,7 +1438,9 @@ impl<'a> Matches<'a> { |
| 1225 | 1438 | rules, |
| 1226 | 1439 | ruled, |
| 1227 | 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 | 7 | Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, |
| 8 | 8 | mix, px, |
| 9 | 9 | }; |
| 10 | use accesskit::{HasPopup, Role}; | |
| 10 | 11 | use std::hash::Hash; |
| 11 | 12 | |
| 12 | 13 | /// A menu's downward arrow. |
| ... | ... | @@ -102,6 +103,7 @@ pub fn section_tabs( |
| 102 | 103 | ], |
| 103 | 104 | fill: Some(fill), |
| 104 | 105 | fade: [fade(offset > 0.5), fade(offset < most - 0.5)], |
| 106 | role: Some(Role::TabList), | |
| 105 | 107 | ..Spec::default() |
| 106 | 108 | }, |
| 107 | 109 | ); |
| ... | ... | @@ -144,13 +146,16 @@ pub fn section_tabs( |
| 144 | 146 | } |
| 145 | 147 | }; |
| 146 | 148 | let lift = f32::from(u8::from(lifted == Some(index))); |
| 147 | let open = ui | |
| 148 | .animate(ui.id(("open", name)), f32::from(u8::from(index == active))) | |
| 149 | .max(lift); | |
| 149 | let open = ui.animate( | |
| 150 | ui.id(("open", name)), | |
| 151 | f32::from(u8::from(index == active || lifted == Some(index))), | |
| 152 | ); | |
| 150 | 153 | // Whole device pixels, so a rising tab reuses its rasterized outlines. |
| 151 | 154 | let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale(); |
| 152 | 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 | 159 | let fade = |color: [f32; 4], alpha: f32| [color[0], color[1], color[2], color[3] * alpha]; |
| 155 | 160 | let signal = ui.leaf( |
| 156 | 161 | ("tab", index), |
| ... | ... | @@ -168,14 +173,18 @@ pub fn section_tabs( |
| 168 | 173 | gradient: Some(fill), |
| 169 | 174 | hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)), |
| 170 | 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 | 177 | shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35 + 0.15 * lift], open)), |
| 173 | 178 | radius: TAB_ROUNDING, |
| 174 | 179 | shape: Shape::Tab { lean: lean(height) }, |
| 175 | 180 | pad: [TAB_PAD, 0.0], |
| 181 | role: Some(Role::Tab), | |
| 176 | 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 | 188 | if signal.clicked && index != active { |
| 180 | 189 | clicked = Some(index); |
| 181 | 190 | } |
| ... | ... | @@ -243,30 +252,53 @@ fn lean(height: f32) -> f32 { |
| 243 | 252 | (height - 2.0) / 2.0 |
| 244 | 253 | } |
| 245 | 254 | |
| 246 | /// A square button showing `icon` tinted by `tint`, lit while `on`; artwork in its own | |
| 247 | /// colours takes white. | |
| 255 | /// A square button showing `icon` tinted by `tint`, a toggle lit while `on` is `Some(true)` | |
| 256 | /// or a plain button where it is `None`; artwork in its own colours takes white. A tooltip | |
| 257 | /// names it. | |
| 248 | 258 | pub fn tool_button( |
| 249 | 259 | ui: &mut Ui, |
| 250 | 260 | part: impl Hash, |
| 251 | 261 | icon: &'static [&'static str], |
| 252 | 262 | tint: [f32; 4], |
| 253 | on: bool, | |
| 263 | on: Option<bool>, | |
| 254 | 264 | ) -> Signal { |
| 255 | 265 | let hover = ui.theme.hover(); |
| 256 | ui.leaf( | |
| 266 | let id = ui.open( | |
| 257 | 267 | part, |
| 258 | 268 | Spec { |
| 259 | 269 | flags: Flags::CLICKABLE, |
| 260 | 270 | size: [px(TOOL), px(TOOL)], |
| 261 | 271 | icon: Some(icon), |
| 262 | 272 | color: Some(tint), |
| 263 | fill: on.then_some(hover), | |
| 273 | fill: (on == Some(true)).then_some(hover), | |
| 264 | 274 | hover_fill: Some(hover), |
| 265 | 275 | radius: 4.0, |
| 266 | 276 | center: true, |
| 277 | role: Some(Role::Button), | |
| 267 | 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. | |
| 287 | fn 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. | |
| 296 | fn 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 | } |
| 271 | 303 | |
| 272 | 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 | 312 | ) { |
| 281 | 313 | let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35]; |
| 282 | 314 | let arrow = faded(ui.theme.text_dim); |
| 283 | ui.open( | |
| 315 | let id = ui.open( | |
| 284 | 316 | part, |
| 285 | 317 | Spec { |
| 286 | 318 | size: [children(), px(TOOL)], |
| 319 | role: Some(Role::Button), | |
| 287 | 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 | 329 | ui.leaf( |
| 291 | 330 | "icon", |
| 292 | 331 | Spec { |
| ... | ... | @@ -312,16 +351,19 @@ pub fn unavailable( |
| 312 | 351 | ui.close(); |
| 313 | 352 | } |
| 314 | 353 | |
| 315 | /// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`, | |
| 316 | /// with the colour it applies as a bar under the icon when given. Hovering the button fills | |
| 317 | /// it alone; hovering the arrow, or its menu being open, outlines both as one control. | |
| 318 | /// Returns the button's signal. | |
| 354 | /// A button `name`d and showing `icon`, a toggle lit while `on` as `tool_button`'s, joined | |
| 355 | /// to an arrow that opens popup `menu`, with the colour it applies as a bar under the icon | |
| 356 | /// when given. Hovering the button fills it alone; hovering the arrow, or its menu being | |
| 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)] | |
| 319 | 360 | pub fn split_button( |
| 320 | 361 | ui: &mut Ui, |
| 321 | 362 | part: impl Hash, |
| 363 | name: &str, | |
| 322 | 364 | icon: &'static [&'static str], |
| 323 | 365 | bar: Option<[f32; 4]>, |
| 324 | on: bool, | |
| 366 | on: Option<bool>, | |
| 325 | 367 | menu: Id, |
| 326 | 368 | ) -> Signal { |
| 327 | 369 | const RADIUS: f32 = 4.0; |
| ... | ... | @@ -332,7 +374,7 @@ pub fn split_button( |
| 332 | 374 | let [button, arrow] = [split.child("button"), split.child("menu")]; |
| 333 | 375 | let lit = ui.animate( |
| 334 | 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 | 379 | let outlined = ui.hover == Some(arrow) || ui.popup_open(menu); |
| 338 | 380 | let ring = ui.animate(split.child("ring"), f32::from(u8::from(outlined))); |
| ... | ... | @@ -348,9 +390,14 @@ pub fn split_button( |
| 348 | 390 | Spec { |
| 349 | 391 | flags: Flags::CLICKABLE | Flags::CLIP, |
| 350 | 392 | size: [px(TOOL), px(TOOL)], |
| 393 | role: Some(Role::Button), | |
| 351 | 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 | 401 | // Rounded past the clip, so the fill meets the arrow square. |
| 355 | 402 | ui.leaf( |
| 356 | 403 | "face", |
| ... | ... | @@ -379,20 +426,24 @@ pub fn split_button( |
| 379 | 426 | } |
| 380 | 427 | ui.close(); |
| 381 | 428 | ui.close(); |
| 382 | if ui | |
| 383 | .leaf( | |
| 384 | "menu", | |
| 385 | Spec { | |
| 386 | flags: Flags::CLICKABLE, | |
| 387 | size: [px(ARROW), px(TOOL)], | |
| 388 | icon: Some(CHEVRON), | |
| 389 | color: Some(ui.theme.text_dim), | |
| 390 | center: true, | |
| 391 | ..Spec::default() | |
| 392 | }, | |
| 393 | ) | |
| 394 | .pressed | |
| 395 | { | |
| 429 | ui.open_as( | |
| 430 | arrow, | |
| 431 | Spec { | |
| 432 | flags: Flags::CLICKABLE, | |
| 433 | size: [px(ARROW), px(TOOL)], | |
| 434 | icon: Some(CHEVRON), | |
| 435 | color: Some(ui.theme.text_dim), | |
| 436 | center: true, | |
| 437 | role: Some(Role::Button), | |
| 438 | ..Spec::default() | |
| 439 | }, | |
| 440 | ); | |
| 441 | opens(ui, arrow, menu); | |
| 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 | 447 | ui.open_popup(menu); |
| 397 | 448 | } |
| 398 | 449 | ui.leaf( |
| ... | ... | @@ -409,12 +460,14 @@ pub fn split_button( |
| 409 | 460 | ui.signal(button) |
| 410 | 461 | } |
| 411 | 462 | |
| 412 | /// A button showing `icon` and a menu arrow, one control that opens popup `menu`. Returns | |
| 413 | /// where the menu opens so its icons line up under the button's. | |
| 463 | /// A button showing `icon` and a menu arrow, one control that opens popup `menu`, a toggle | |
| 464 | /// lit while `on` as `tool_button`'s. Returns where the menu opens so its icons line up | |
| 465 | /// under the button's. | |
| 414 | 466 | pub fn menu_button( |
| 415 | 467 | ui: &mut Ui, |
| 416 | 468 | part: impl Hash, |
| 417 | 469 | icon: &'static [&'static str], |
| 470 | on: Option<bool>, | |
| 418 | 471 | menu: Id, |
| 419 | 472 | ) -> Anchor { |
| 420 | 473 | let theme = ui.theme.clone(); |
| ... | ... | @@ -423,12 +476,15 @@ pub fn menu_button( |
| 423 | 476 | Spec { |
| 424 | 477 | flags: Flags::CLICKABLE, |
| 425 | 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 | 480 | hover_fill: Some(theme.hover()), |
| 428 | 481 | radius: 4.0, |
| 482 | role: Some(Role::Button), | |
| 429 | 483 | ..Spec::default() |
| 430 | 484 | }, |
| 431 | 485 | ); |
| 486 | opens(ui, id, menu); | |
| 487 | press_state(ui, id, on); | |
| 432 | 488 | for (part, icon, width, color) in [ |
| 433 | 489 | ("icon", icon, TOOL, theme.text), |
| 434 | 490 | ("arrow", CHEVRON, ARROW, theme.text_dim), |
| ... | ... | @@ -455,27 +511,53 @@ pub fn menu_button( |
| 455 | 511 | Anchor::Below([left - shift, top, right - shift, bottom]) |
| 456 | 512 | } |
| 457 | 513 | |
| 458 | /// A drop-down box `width` wide showing `text`. | |
| 459 | pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>) -> Signal { | |
| 514 | /// A drop-down box `name`d, `width` wide, showing `text`, that opens popup `menu`; where | |
| 515 | /// not `enabled` it shows faded and takes no clicks or focus, as `unavailable` buttons do. | |
| 516 | pub 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 | 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 | 529 | let id = ui.open( |
| 462 | 530 | part, |
| 463 | 531 | Spec { |
| 464 | flags: Flags::CLICKABLE, | |
| 532 | flags: if enabled { | |
| 533 | Flags::CLICKABLE | |
| 534 | } else { | |
| 535 | Flags::default() | |
| 536 | }, | |
| 465 | 537 | size: [width.into(), px(TOOL)], |
| 466 | 538 | fill: Some(theme.base), |
| 467 | hover_border: Some(theme.accent), | |
| 468 | border: Some(theme.chip), | |
| 539 | hover_border: enabled.then_some(theme.accent), | |
| 540 | border: Some(faded(theme.chip)), | |
| 469 | 541 | radius: 4.0, |
| 470 | 542 | pad: [8.0, 0.0], |
| 543 | role: Some(Role::ComboBox), | |
| 471 | 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 | 555 | ui.leaf( |
| 475 | 556 | "text", |
| 476 | 557 | Spec { |
| 477 | 558 | size: [fill(), px(TOOL)], |
| 478 | 559 | text: Some(text), |
| 560 | color: Some(faded(theme.text)), | |
| 479 | 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 | 566 | Spec { |
| 485 | 567 | size: [px(12.0), px(TOOL)], |
| 486 | 568 | icon: Some(CHEVRON), |
| 487 | color: Some(theme.text_dim), | |
| 569 | color: Some(faded(theme.text_dim)), | |
| 488 | 570 | center: true, |
| 489 | 571 | ..Spec::default() |
| 490 | 572 | }, |
| 491 | 573 | ); |
| 492 | 574 | ui.close(); |
| 493 | ui.signal(id) | |
| 575 | if enabled && ui.signal(id).pressed { | |
| 576 | ui.open_popup(menu); | |
| 577 | } | |
| 494 | 578 | } |
| 495 | 579 | |
| 496 | 580 | impl Ui { |
crates/ui/src/tests.rs+231-31| ... | ... | @@ -326,6 +326,38 @@ fn clicks_use_the_previous_layout_and_hover_animates() { |
| 326 | 326 | assert!(!ui.wants_frame()); |
| 327 | 327 | } |
| 328 | 328 | |
| 329 | /// Two boxes swapping places under a still pointer, as dropped tabs reorder, pass the hover | |
| 330 | /// to the one that arrives. | |
| 331 | #[test] | |
| 332 | fn 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 | 361 | /// A click whose press and release arrive together starts an animation that a box built |
| 330 | 362 | /// earlier in the frame reads: the frame that handled the click asks for the next one. |
| 331 | 363 | #[test] |
| ... | ... | @@ -434,12 +466,16 @@ fn custom_boxes_receive_their_events_and_a_leave_when_the_pointer_moves_off() { |
| 434 | 466 | ui.cursor().is_none(), |
| 435 | 467 | "the host picks the cursor over its box" |
| 436 | 468 | ); |
| 469 | ui.event(Event::Pressure(Some(0.5))); | |
| 437 | 470 | ui.event(Event::PointerMoved([350.0, 20.0])); |
| 438 | 471 | let signals = build(&mut ui); |
| 439 | 472 | assert_eq!( |
| 440 | 473 | signals[0].events, |
| 441 | [Event::PointerMoved([350.0, 20.0])], | |
| 442 | "a drag keeps its box" | |
| 474 | [ | |
| 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 | 480 | ui.event(Event::Button { |
| 445 | 481 | button: MouseButton::Left, |
| ... | ... | @@ -1299,9 +1335,9 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { |
| 1299 | 1335 | let build = |ui: &mut Ui| { |
| 1300 | 1336 | let mut chosen = None; |
| 1301 | 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 | chosen | |
| 1340 | chosen.map(|(_, index)| index) | |
| 1305 | 1341 | }; |
| 1306 | 1342 | build(&mut ui); |
| 1307 | 1343 | ui.open_popup(palette); |
| ... | ... | @@ -1320,11 +1356,42 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { |
| 1320 | 1356 | ); |
| 1321 | 1357 | } |
| 1322 | 1358 | |
| 1359 | #[test] | |
| 1360 | fn 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 | 1388 | #[test] |
| 1324 | 1389 | fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { |
| 1325 | 1390 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 1326 | 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 | 1395 | let build = |ui: &mut Ui| { |
| 1329 | 1396 | let mut chosen = None; |
| 1330 | 1397 | frame(ui, |ui| { |
| ... | ... | @@ -1344,7 +1411,7 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { |
| 1344 | 1411 | ] { |
| 1345 | 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 | 1415 | ui.open_popup(grid); |
| 1349 | 1416 | for _ in 0..40 { |
| 1350 | 1417 | build(&mut ui); |
| ... | ... | @@ -1423,6 +1490,64 @@ fn popups_opened_within_a_popup_stay_above_it() { |
| 1423 | 1490 | assert!(ui.popup_open(menu_id()) && !ui.popup_open(submenu)); |
| 1424 | 1491 | } |
| 1425 | 1492 | |
| 1493 | #[test] | |
| 1494 | fn 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 | 1551 | /// Keys in order, counting the lookups a list makes. |
| 1427 | 1552 | struct Keyed { |
| 1428 | 1553 | keys: Vec<u64>, |
| ... | ... | @@ -1635,6 +1760,39 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { |
| 1635 | 1760 | ); |
| 1636 | 1761 | } |
| 1637 | 1762 | |
| 1763 | #[test] | |
| 1764 | fn 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 | ||
| 1638 | 1796 | const FONTS: [&str; 46] = [ |
| 1639 | 1797 | "American Typewriter", |
| 1640 | 1798 | "Andale Mono", |
| ... | ... | @@ -1764,7 +1922,7 @@ fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { |
| 1764 | 1922 | } |
| 1765 | 1923 | |
| 1766 | 1924 | #[test] |
| 1767 | fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { | |
| 1925 | fn a_filtered_menu_shows_its_new_results_at_once_from_the_top() { | |
| 1768 | 1926 | let fonts = fonts(); |
| 1769 | 1927 | let build = |ui: &mut Ui| { |
| 1770 | 1928 | frame(ui, |ui| { |
| ... | ... | @@ -1777,35 +1935,35 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { |
| 1777 | 1935 | for _ in 0..12 { |
| 1778 | 1936 | build(&mut ui); |
| 1779 | 1937 | } |
| 1780 | let results = menu_id().child("results"); | |
| 1938 | let results = menu_id().child("rows"); | |
| 1781 | 1939 | let height = |ui: &Ui| { |
| 1782 | 1940 | let rect = ui.rect(results).unwrap(); |
| 1783 | 1941 | rect[3] - rect[1] |
| 1784 | 1942 | }; |
| 1785 | let tall = height(&ui); | |
| 1786 | 1943 | assert!( |
| 1787 | !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW, | |
| 1944 | !ui.wants_frame() && height(&ui) > 5.0 * popup::MENU_ROW, | |
| 1788 | 1945 | "open at its full height" |
| 1789 | 1946 | ); |
| 1790 | ui.event(typed("tnr")); | |
| 1791 | build(&mut ui); | |
| 1792 | build(&mut ui); | |
| 1793 | let row = ui.rect(menu_id().child("rows").child(42_u64)).unwrap(); | |
| 1947 | ui.event(key(NamedKey::End)); | |
| 1948 | for _ in 0..40 { | |
| 1949 | build(&mut ui); | |
| 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 | 1961 | assert_eq!( |
| 1795 | 1962 | row[1], |
| 1796 | 1963 | ui.rect(results).unwrap()[1], |
| 1797 | 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 | 1966 | assert_eq!(height(&ui), popup::MENU_ROW); |
| 1808 | assert!(frames <= 10, "settles in {frames} frames"); | |
| 1809 | 1967 | } |
| 1810 | 1968 | |
| 1811 | 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 | 2133 | assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15); |
| 1976 | 2134 | assert_eq!( |
| 1977 | 2135 | first.pivot, |
| 1978 | [200.0, 125.0], | |
| 1979 | "the dialog swings from its top centre" | |
| 2136 | [200.0, 300.0 / 8.0], | |
| 2137 | "the dialog swings from its top centre, an eighth down" | |
| 1980 | 2138 | ); |
| 1981 | 2139 | for _ in 0..16 { |
| 1982 | 2140 | frame(&mut ui, build); |
| ... | ... | @@ -2070,9 +2228,15 @@ fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported { |
| 2070 | 2228 | }, |
| 2071 | 2229 | ); |
| 2072 | 2230 | let combo = ui.id("combo"); |
| 2073 | if shell::combo(ui, "combo", "Light", 120.0).pressed { | |
| 2074 | ui.open_popup(Id::ROOT.child("schemes")); | |
| 2075 | } | |
| 2231 | shell::combo( | |
| 2232 | ui, | |
| 2233 | "combo", | |
| 2234 | "Color scheme", | |
| 2235 | "Light", | |
| 2236 | 120.0, | |
| 2237 | Id::ROOT.child("schemes"), | |
| 2238 | true, | |
| 2239 | ); | |
| 2076 | 2240 | let items = ["System", "Light", "Dark"].map(|text| popup::Item { |
| 2077 | 2241 | text, |
| 2078 | 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 | 2290 | let mut chosen = None; |
| 2127 | 2291 | let mut build = |ui: &mut Ui| { |
| 2128 | 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 | 2294 | let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); |
| 2131 | 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 | 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 | 2403 | let item = popup::Item { |
| 2240 | 2404 | text: "Item", |
| 2241 | 2405 | ..Default::default() |
| ... | ... | @@ -2331,7 +2495,7 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() { |
| 2331 | 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 | 2499 | popup::menu(ui, menu_id(), anchor, &items, None); |
| 2336 | 2500 | ui.close(); |
| 2337 | 2501 | }); |
| ... | ... | @@ -2543,3 +2707,39 @@ fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() { |
| 2543 | 2707 | assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2])); |
| 2544 | 2708 | assert!(!ui.popup_open(picker)); |
| 2545 | 2709 | } |
| 2710 | ||
| 2711 | #[test] | |
| 2712 | fn 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 | 47 | .context |
| 48 | 48 | .ranged_builder(&mut self.fonts, text, 1.0, false); |
| 49 | 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-ui | |
| 51 | // doesn't name. | |
| 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 | ]; | |
| 50 | let system = | |
| 51 | [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic); | |
| 57 | 52 | let families: Vec<_> = font |
| 58 | 53 | .into_iter() |
| 59 | 54 | .map(FontFamilyName::named) |
| 60 | .chain(system.into_iter().flatten()) | |
| 55 | .chain(system) | |
| 61 | 56 | .collect(); |
| 62 | 57 | builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned( |
| 63 | 58 | families, |
| ... | ... | @@ -99,6 +94,15 @@ impl Texts { |
| 99 | 94 | self.label(&cut, size, *bold, font, frame) |
| 100 | 95 | } |
| 101 | 96 | |
| 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 | 106 | pub fn preview_font(&mut self, data: Blob<u8>, family: &str) { |
| 103 | 107 | self.fonts.collection.register_fonts( |
| 104 | 108 | data, |
crates/ui/src/widgets.rs+72-10| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px}; |
| 2 | use accesskit::Role; | |
| 2 | 3 | use draw::RasterImage; |
| 3 | 4 | use draw::edit::{self, Command, Movement}; |
| 4 | 5 | use parley::{ |
| ... | ... | @@ -25,6 +26,7 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal { |
| 25 | 26 | radius: 4.0, |
| 26 | 27 | pad: [theme.font_size * 0.75, 0.0], |
| 27 | 28 | center: true, |
| 29 | role: Some(Role::Button), | |
| 28 | 30 | ..Spec::default() |
| 29 | 31 | }; |
| 30 | 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 | 44 | flags: Flags::CLICKABLE, |
| 43 | 45 | size: [crate::children(), px(height)], |
| 44 | 46 | gap: 8.0, |
| 47 | role: Some(Role::CheckBox), | |
| 45 | 48 | ..Spec::default() |
| 46 | 49 | }, |
| 47 | 50 | ); |
| 51 | if let Some(node) = ui.access(id) { | |
| 52 | node.set_toggled(checked.into()); | |
| 53 | } | |
| 48 | 54 | // A box's signal is taken once, so its hover and click are read together. |
| 49 | 55 | let signal = ui.signal(id); |
| 50 | 56 | ui.leaf( |
| ... | ... | @@ -303,6 +309,48 @@ pub fn text_field( |
| 303 | 309 | placeholder: &str, |
| 304 | 310 | spec: Spec<'_>, |
| 305 | 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. | |
| 316 | pub 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 | ||
| 326 | fn 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 | 354 | let signal = ui.signal(id); |
| 307 | 355 | let pad = spec.pad[0]; |
| 308 | 356 | let size = ui.theme.font_size; |
| ... | ... | @@ -312,13 +360,13 @@ pub fn text_field( |
| 312 | 360 | .zip(ui.rect(id)) |
| 313 | 361 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) |
| 314 | 362 | .filter(|_| signal.pressed || signal.dragging); |
| 315 | let (mut selection, mut press, select_all) = { | |
| 316 | let (selection, press, select_all) = ui.field(id); | |
| 317 | (*selection, *press, std::mem::take(select_all)) | |
| 363 | let (mut selection, mut press, select) = { | |
| 364 | let (selection, press, select) = ui.field(id); | |
| 365 | (*selection, *press, select.take()) | |
| 318 | 366 | }; |
| 319 | 367 | let before = [selection.anchor(), selection.focus()]; |
| 320 | 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 | 370 | if let Some(x) = pointer { |
| 323 | 371 | let layout = &label.layout; |
| 324 | 372 | let unit = if signal.pressed { signal.unit } else { press.1 }; |
| ... | ... | @@ -342,10 +390,10 @@ pub fn text_field( |
| 342 | 390 | Selection::new(anchor, focus) |
| 343 | 391 | }; |
| 344 | 392 | } |
| 345 | selection = if select_all { | |
| 393 | selection = if let Some([anchor, focus]) = select { | |
| 346 | 394 | Selection::new( |
| 347 | Cursor::from_byte_index(&label.layout, 0, Affinity::Downstream), | |
| 348 | Cursor::from_byte_index(&label.layout, usize::MAX, Affinity::Upstream), | |
| 395 | Cursor::from_byte_index(&label.layout, anchor, Affinity::Downstream), | |
| 396 | Cursor::from_byte_index(&label.layout, focus, Affinity::Upstream), | |
| 349 | 397 | ) |
| 350 | 398 | } else { |
| 351 | 399 | selection.refresh(&label.layout) |
| ... | ... | @@ -419,11 +467,13 @@ pub fn text_field( |
| 419 | 467 | } |
| 420 | 468 | _ => continue, |
| 421 | 469 | }; |
| 470 | let range = to_text(text, range.start)..to_text(text, range.end); | |
| 422 | 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 | 474 | selection = Selection::from_byte_index( |
| 425 | 475 | &label.layout, |
| 426 | range.start + inserted.len(), | |
| 476 | to_shown(text, range.start + inserted.len()), | |
| 427 | 477 | Affinity::Downstream, |
| 428 | 478 | ); |
| 429 | 479 | } |
| ... | ... | @@ -437,7 +487,7 @@ pub fn text_field( |
| 437 | 487 | let shown = if text.is_empty() { |
| 438 | 488 | placeholder |
| 439 | 489 | } else { |
| 440 | text.as_str() | |
| 490 | shown_text.as_str() | |
| 441 | 491 | }; |
| 442 | 492 | let height = spec.size[1]; |
| 443 | 493 | let backdrop = spec.fill.unwrap_or(theme.base); |
| ... | ... | @@ -452,9 +502,20 @@ pub fn text_field( |
| 452 | 502 | theme.text |
| 453 | 503 | }), |
| 454 | 504 | cursor: Some(CursorIcon::Text), |
| 505 | role: spec.role.or(Some(if secret { | |
| 506 | Role::PasswordInput | |
| 507 | } else { | |
| 508 | Role::TextInput | |
| 509 | })), | |
| 455 | 510 | ..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 | 519 | if signal.focused { |
| 459 | 520 | let line = label.size[1]; |
| 460 | 521 | let top = match height.size { |
| ... | ... | @@ -508,6 +569,7 @@ pub fn edit_key(key: &Key) -> edit::Key { |
| 508 | 569 | NamedKey::End => Edit::End, |
| 509 | 570 | NamedKey::PageUp => Edit::PageUp, |
| 510 | 571 | NamedKey::PageDown => Edit::PageDown, |
| 572 | NamedKey::F7 => Edit::F7, | |
| 511 | 573 | NamedKey::F11 => Edit::F11, |
| 512 | 574 | NamedKey::Alt |
| 513 | 575 | | NamedKey::AltGraph |
platform/snow-leopard/Cargo.lock+23| ... | ... | @@ -18,6 +18,15 @@ version = "0.1.10" |
| 18 | 18 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 19 | 19 | checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" |
| 20 | 20 | |
| 21 | [[package]] | |
| 22 | name = "accesskit" | |
| 23 | version = "0.25.1" | |
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" | |
| 26 | dependencies = [ | |
| 27 | "uuid", | |
| 28 | ] | |
| 29 | ||
| 21 | 30 | [[package]] |
| 22 | 31 | name = "adler2" |
| 23 | 32 | version = "2.0.1" |
| ... | ... | @@ -116,6 +125,12 @@ version = "1.5.1" |
| 116 | 125 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 117 | 126 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" |
| 118 | 127 | |
| 128 | [[package]] | |
| 129 | name = "base64" | |
| 130 | version = "0.22.1" | |
| 131 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 133 | ||
| 119 | 134 | [[package]] |
| 120 | 135 | name = "bit-set" |
| 121 | 136 | version = "0.10.0" |
| ... | ... | @@ -413,6 +428,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" |
| 413 | 428 | name = "draw" |
| 414 | 429 | version = "0.1.0" |
| 415 | 430 | dependencies = [ |
| 431 | "base64", | |
| 416 | 432 | "bytemuck", |
| 417 | 433 | "image", |
| 418 | 434 | "linebender_resource_handle", |
| ... | ... | @@ -2301,6 +2317,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" |
| 2301 | 2317 | name = "ui" |
| 2302 | 2318 | version = "0.1.0" |
| 2303 | 2319 | dependencies = [ |
| 2320 | "accesskit", | |
| 2304 | 2321 | "draw", |
| 2305 | 2322 | "nucleo-matcher", |
| 2306 | 2323 | "parley", |
| ... | ... | @@ -2331,6 +2348,12 @@ version = "1.0.4" |
| 2331 | 2348 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2332 | 2349 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" |
| 2333 | 2350 | |
| 2351 | [[package]] | |
| 2352 | name = "uuid" | |
| 2353 | version = "1.26.1" | |
| 2354 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2355 | checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" | |
| 2356 | ||
| 2334 | 2357 | [[package]] |
| 2335 | 2358 | name = "version_check" |
| 2336 | 2359 | version = "0.9.5" |
platform/snow-leopard/cargo.sh+5-1| ... | ... | @@ -11,11 +11,15 @@ shift |
| 11 | 11 | runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12) |
| 12 | 12 | # The deployment target is for cc-rs (bundled SQLite); rustc warns that it ignores it. |
| 13 | 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. | |
| 14 | 17 | CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \ |
| 15 | 18 | SNOW_LEOPARD_SDK=$sdk \ |
| 16 | 19 | TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \ |
| 17 | 20 | MACOSX_DEPLOYMENT_TARGET=10.6 \ |
| 18 | 21 | exec cargo +nightly -Zbuild-std=std,panic_unwind -Zjson-target-spec \ |
| 19 | 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 | 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 | ||
| 21 | static NSString *dir; | |
| 22 | ||
| 23 | static 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 | ||
| 30 | static 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 | ||
| 41 | int 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 | // Writes the main display to a PNG, for a session reached over ssh, where | |
| 1 | // 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 | 3 | // screencapture exits 0 without writing a file. Wakes a sleeping display first: asleep, |
| 3 | 4 | // it shows its last frame. |
| 4 | 5 | #include <ApplicationServices/ApplicationServices.h> |
| ... | ... | @@ -7,16 +8,45 @@ |
| 7 | 8 | #include <string.h> |
| 8 | 9 | #include <unistd.h> |
| 9 | 10 | |
| 11 | static 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 | ||
| 10 | 29 | int main(int argc, char **argv) { |
| 11 | if (argc != 2) { | |
| 12 | fprintf(stderr, "usage: screen OUT.png\n"); | |
| 30 | if (argc != 2 && argc != 3) { | |
| 31 | fprintf(stderr, "usage: screen OUT.png [OWNER]\n"); | |
| 13 | 32 | return 2; |
| 14 | 33 | } |
| 15 | 34 | UpdateSystemActivity(UsrActivity); |
| 16 | 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 | 48 | if (!image) { |
| 19 | fprintf(stderr, "screen: no image of the display\n"); | |
| 49 | fprintf(stderr, "screen: no image\n"); | |
| 20 | 50 | return 1; |
| 21 | 51 | } |
| 22 | 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 | 13 | let text = std::fs::read_to_string(&file).unwrap(); |
| 14 | 14 | let meta = std::fs::metadata(&file).unwrap(); |
| 15 | 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 | 18 | std::fs::remove_dir_all(&dir).unwrap(); |
| 17 | 19 | |
| 18 | 20 | let start = Instant::now(); |
| ... | ... | @@ -33,7 +35,7 @@ fn main() { |
| 33 | 35 | map.insert("random-state", 1); |
| 34 | 36 | |
| 35 | 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 | 39 | println!("tls: threads {counts:?}, main {}", COUNTER.with(Cell::get)); |
| 38 | 40 | println!("time: {}s since epoch, {:?} elapsed", since_epoch.as_secs(), start.elapsed()); |
| 39 | 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 | 20 | # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas) |
| 21 | 21 | # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it. |
| 22 | 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 | 25 | esac |
| 24 | 26 | fi |
| 25 | 27 | [ "$prev" = -arch ] && arch=$arg |
platform/snow-leopard/remote.sh+18-7| ... | ... | @@ -7,12 +7,14 @@ |
| 7 | 7 | # remote.sh start BINARY [ARGS...] ship and leave running there, output in NAME.log; |
| 8 | 8 | # BINARY may be an .app bundle |
| 9 | 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 (screencapture | |
| 11 | # writes nothing from ssh) | |
| 10 | # remote.sh screen LOCAL.png [APP] wake the display and capture it, or APP's window with | |
| 11 | # its shadow (screencapture writes nothing from ssh) | |
| 12 | 12 | # remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES |
| 13 | 13 | # | type TEXT | key KEYCODE [command|shift|option|control]... |
| 14 | 14 | # remote.sh selectors BINARY selector-like names BINARY holds that nothing on |
| 15 | 15 | # 10.6 implements |
| 16 | # remote.sh art fetch 10.6's window buttons and scroller pieces | |
| 17 | # into crates/snowbound/assets/snow-leopard | |
| 16 | 18 | # remote.sh mirror PATH... copy repo paths to the same absolute paths there, so |
| 17 | 19 | # test binaries find fixtures by CARGO_MANIFEST_DIR |
| 18 | 20 | # remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot) |
| ... | ... | @@ -37,10 +39,11 @@ ship() { |
| 37 | 39 | tool() { |
| 38 | 40 | sdk=$here/../../target/snow-leopard/MacOSX10.6.sdk |
| 39 | 41 | out=$here/../../target/snow-leopard/$1 |
| 40 | if [ ! "$out" -nt "$here/console/$1.c" ]; then | |
| 42 | source=$(ls "$here/console/$1".[cm]) | |
| 43 | if [ ! "$out" -nt "$source" ]; then | |
| 41 | 44 | clang -arch x86_64 -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \ |
| 42 | -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 || true | |
| 45 | -framework AppKit -framework ApplicationServices -framework CoreServices -lobjc \ | |
| 46 | "$source" -o "$out" 2>&1 | grep -v -e "MH_DYLIB_STUB" -e "no platform load" >&2 || true | |
| 44 | 47 | fi |
| 45 | 48 | ship "$out" |
| 46 | 49 | } |
| ... | ... | @@ -88,7 +91,8 @@ stop) |
| 88 | 91 | ;; |
| 89 | 92 | screen) |
| 90 | 93 | 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 | ;; |
| 93 | 97 | input) |
| 94 | 98 | shift |
| ... | ... | @@ -101,6 +105,13 @@ selectors) |
| 101 | 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 | 106 | grep -E '[a-z]{2}[A-Z][a-z]|:$' | sort -u | ssh "$host" "$dir/selectors 2>/dev/null" |
| 103 | 107 | ;; |
| 108 | art) | |
| 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 | ;; | |
| 104 | 115 | mirror) |
| 105 | 116 | shift |
| 106 | 117 | root=$(cd "$here/../.." && pwd) |
| ... | ... | @@ -120,7 +131,7 @@ sh) |
| 120 | 131 | ssh "$host" "$@" |
| 121 | 132 | ;; |
| 122 | 133 | *) |
| 123 | sed -n '2,21s/^# \{0,1\}//p' "$0" >&2 | |
| 134 | sed -n '2,23s/^# \{0,1\}//p' "$0" >&2 | |
| 124 | 135 | exit 2 |
| 125 | 136 | ;; |
| 126 | 137 | esac |
platform/snow-leopard/rt/appkit.c+7| ... | ... | @@ -22,6 +22,13 @@ const CFStringRef NSAccessibilityTabButtonSubrole = CFSTR("AXTabButton"); |
| 22 | 22 | const CFStringRef NSAccessibilityTextAlignmentAttribute = CFSTR("AXTextAlignment"); |
| 23 | 23 | const CFStringRef NSAccessibilityToggleSubrole = CFSTR("AXToggle"); |
| 24 | 24 | |
| 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. | |
| 27 | const CFStringRef NSURLIsUbiquitousItemKey = CFSTR("NSURLIsUbiquitousItemKey"); | |
| 28 | const CFStringRef NSURLUbiquitousItemDownloadingStatusKey = CFSTR("NSURLUbiquitousItemDownloadingStatusKey"); | |
| 29 | const CFStringRef NSURLUbiquitousItemDownloadingStatusNotDownloaded = | |
| 30 | CFSTR("NSURLUbiquitousItemDownloadingStatusNotDownloaded"); | |
| 31 | ||
| 25 | 32 | extern void NSAccessibilityPostNotification(id element, CFStringRef notification); |
| 26 | 33 | |
| 27 | 34 | void 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 | ||
| 7 | const CFStringRef AVFormatIDKey = CFSTR("AVFormatIDKey"); | |
| 8 | const CFStringRef AVSampleRateKey = CFSTR("AVSampleRateKey"); | |
| 9 | const CFStringRef AVNumberOfChannelsKey = CFSTR("AVNumberOfChannelsKey"); | |
| 10 | const CFStringRef AVLinearPCMBitDepthKey = CFSTR("AVLinearPCMBitDepthKey"); | |
| 11 | const CFStringRef AVLinearPCMIsFloatKey = CFSTR("AVLinearPCMIsFloatKey"); | |
| 12 | const CFStringRef AVLinearPCMIsBigEndianKey = CFSTR("AVLinearPCMIsBigEndianKey"); | |
| 13 | const CFStringRef AVLinearPCMIsNonInterleaved = CFSTR("AVLinearPCMIsNonInterleaved"); | |
| 14 | const CFStringRef AVMediaTypeAudio = CFSTR("soun"); | |
| 15 | const CFStringRef AVMediaTypeVideo = CFSTR("vide"); | |
| 16 | const CFStringRef AVCaptureSessionPreset320x240 = CFSTR("AVCaptureSessionPreset320x240"); | |
| 17 | ||
| 18 | typedef struct { | |
| 19 | long long value; | |
| 20 | int timescale; | |
| 21 | unsigned flags; | |
| 22 | long long epoch; | |
| 23 | } CMTime; | |
| 24 | ||
| 25 | void *CMSampleBufferGetImageBuffer(void *sample) { return NULL; } | |
| 26 | void *CMSampleBufferGetDataBuffer(void *sample) { return NULL; } | |
| 27 | size_t CMBlockBufferGetDataLength(void *buffer) { return 0; } | |
| 28 | int CMBlockBufferCopyDataBytes(void *buffer, size_t offset, size_t length, void *destination) { | |
| 29 | return -1; | |
| 30 | } | |
| 31 | CMTime 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 | #include <limits.h> |
| 9 | 9 | #include <math.h> |
| 10 | 10 | #include <mach/mach_time.h> |
| 11 | #include <pthread.h> | |
| 11 | 12 | #include <stdarg.h> |
| 12 | 13 | #include <stdint.h> |
| 14 | #include <stdlib.h> | |
| 13 | 15 | #include <string.h> |
| 14 | 16 | #include <sys/stat.h> |
| 15 | 17 | #include <sys/time.h> |
| ... | ... | @@ -105,6 +107,30 @@ int fclonefileat(int fd, int dirfd, const char *to, int flags) { |
| 105 | 107 | |
| 106 | 108 | // Callers keep using `fd` (std's remove_dir_all passes it to openat/unlinkat), so the |
| 107 | 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. | |
| 113 | static pthread_key_t entry_key; | |
| 114 | static pthread_once_t entry_once = PTHREAD_ONCE_INIT; | |
| 115 | static void make_entry_key(void) { pthread_key_create(&entry_key, free); } | |
| 116 | ||
| 117 | struct dirent *readdir(DIR *dir) __asm("_readdir" __DARWIN_SUF_64_BIT_INO_T); | |
| 118 | struct 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 | ||
| 108 | 134 | DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03); |
| 109 | 135 | DIR *fdopendir(int fd) { |
| 110 | 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 | |
| 4 | platform, into Clover's NAS folder `/Volumes/clover/Documents/Public/Snowbound/`, | |
| 5 | which `https://file.paperclover.net/shr/snowbound/` serves read-only. The app | |
| 6 | checks that URL and updates itself. | |
| 7 | ||
| 8 | ## Versions | |
| 9 | ||
| 10 | A build is named by its commit on `main`: the day the commit was made, in | |
| 11 | Los Angeles, and how many commits among it and its ancestors were made that | |
| 12 | day, itself included. Commit times are jj's committer timestamps. The fourth | |
| 13 | commit 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 | |
| 15 | build's version is compiled in from `SNOWBOUND_BUILD`; builds without it are | |
| 16 | development builds, which never update themselves. | |
| 17 | ||
| 18 | ## The published folder | |
| 19 | ||
| 20 | ```text | |
| 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "linux-aarch64": ...} | |
| 22 | 2026-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 | ||
| 31 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into | |
| 32 | place, and never changed or deleted. `latest.json` is replaced last, through a | |
| 33 | rename, and only moves a platform forward. It carries no signature: the trust is | |
| 34 | in the immutable `build.json`, whose version the app checks against the one it | |
| 35 | was pointed to, and a forged pointer can only name another signed build, which | |
| 36 | the app ignores unless it is newer than itself. | |
| 37 | ||
| 38 | ## Signing | |
| 39 | ||
| 40 | Every `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). | |
| 42 | Its public half is `crates/snowbound/release-key.pub`, compiled into the app. | |
| 43 | `cargo run -p snowbound --example release_sign -- KEY FILE...` prints | |
| 44 | signatures 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 | |
| 46 | build with the new public half, signed with the old key. | |
| 47 | ||
| 48 | The 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 | |
| 50 | the account holder's legal name, which nothing here prints or stores. It gets | |
| 51 | hardened runtime, a secure timestamp, and the microphone and camera | |
| 52 | entitlements recording needs. `--ad-hoc` signs ad hoc instead; the 10.6 bundle | |
| 53 | stays unsigned, as it predates Developer ID. codesign fails with | |
| 54 | `errSecInternalComponent` where it can't ask to use the private key, as from an | |
| 55 | agent'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 | |
| 57 | asking. | |
| 58 | ||
| 59 | Notarization runs when the keychain profile `snowbound` exists and signs in, | |
| 60 | and is skipped with a note otherwise. Make the profile once, with an App Store | |
| 61 | Connect API key (Users and Access, Integrations, Team Keys: a key with the | |
| 62 | Developer role; its `.p8`, key ID and issuer ID): | |
| 63 | ||
| 64 | ```sh | |
| 65 | xcrun notarytool store-credentials snowbound --key AuthKey_KEYID.p8 --key-id KEYID --issuer ISSUER-UUID | |
| 66 | ``` | |
| 67 | ||
| 68 | or 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`. | |
| 70 | Until the app is notarized, a download opened in Finder needs Open from its | |
| 71 | context menu the first time; updates the app installs itself carry no | |
| 72 | quarantine and open directly. | |
| 73 | ||
| 74 | ## Publishing | |
| 75 | ||
| 76 | ```sh | |
| 77 | python3 tools/release.py # all four platforms | |
| 78 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID | |
| 79 | python3 tools/release.py --dry-run # the same into a new temporary folder | |
| 80 | python3 tools/release.py --platforms macos-aarch64 linux-x86_64 | |
| 81 | ``` | |
| 82 | ||
| 83 | The script refuses to run unless the working copy matches `main` (a dry run | |
| 84 | only warns), runs Clippy (workspace, and `snowbound` without default features) | |
| 85 | and 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 | |
| 88 | the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and | |
| 89 | publishes 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 | |
| 91 | refused. 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 | |
| 97 | published build, it checks shortly after launch and then daily, skipping while | |
| 98 | Work Offline is on; Check for Updates… (the app menu on macOS, the command | |
| 99 | palette 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 | |
| 101 | downloads the archive for this platform, verifying size, SHA-256 and | |
| 102 | signature. It stages the update beside the install, so the swap is a rename: | |
| 103 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the | |
| 104 | executable into `.snowbound.update` next to it on Linux. The sync status icon gets a dot, and its popup offers | |
| 105 | Build Folder and Restart to Update. | |
| 106 | ||
| 107 | Restart 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 | |
| 109 | executable) over the old one, and opens it. Where the install can't be | |
| 110 | written, and on Mac OS X 10.6, the popup and the check name the build and | |
| 111 | open its folder to download instead. |
tools/canvas/README.md+4-4| ... | ... | @@ -14,13 +14,13 @@ cargo test -p draw -- --ignored |
| 14 | 14 | cargo test -p canvas --features gpu gpu:: -- --ignored |
| 15 | 15 | ``` |
| 16 | 16 | |
| 17 | The 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: | |
| 17 | The 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: | |
| 18 | 18 | |
| 19 | 19 | ```sh |
| 20 | python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id dev.snowbound.review | |
| 20 | python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review | |
| 21 | 21 | ``` |
| 22 | 22 | |
| 23 | The 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. | |
| 23 | The 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. | |
| 24 | 24 | |
| 25 | 25 | Control-Command-Space opens the macOS character picker for the active text field. |
| 26 | 26 | |
| ... | ... | @@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed, |
| 54 | 54 | |
| 55 | 55 | The `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. |
| 56 | 56 | |
| 57 | A 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"}`. | |
| 57 | A 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"}`. | |
| 58 | 58 | |
| 59 | 59 | ```sh |
| 60 | 60 | cp -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 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one.""" |
| 3 | 3 | import argparse |
| 4 | from datetime import datetime | |
| 4 | 5 | import json |
| 6 | import os | |
| 5 | 7 | from pathlib import Path |
| 6 | 8 | import platform |
| 7 | 9 | import plistlib |
| 10 | import re | |
| 8 | 11 | import shutil |
| 9 | 12 | import subprocess |
| 10 | 13 | import tempfile |
| ... | ... | @@ -12,15 +15,63 @@ import tempfile |
| 12 | 15 | parser = argparse.ArgumentParser(description=__doc__) |
| 13 | 16 | parser.add_argument('--release', action='store_true') |
| 14 | 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') |
| 15 | parser.add_argument('--bundle-id', help='Bundle identifier for the separate app') | |
| 18 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") | |
| 16 | 19 | parser.add_argument('--snow-leopard', action='store_true', |
| 17 | 20 | help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') |
| 21 | parser.add_argument('--sign-identity', metavar='SHA1', | |
| 22 | help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise") | |
| 23 | parser.add_argument('--profile', type=Path, | |
| 24 | help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise') | |
| 18 | 25 | args = parser.parse_args() |
| 19 | if bool(args.output) != bool(args.bundle_id): | |
| 20 | parser.error('Use --output and --bundle-id together.') | |
| 26 | if args.bundle_id and not args.output: | |
| 27 | parser.error('Use --bundle-id with --output.') | |
| 21 | 28 | if args.output and (args.output.suffix != '.app' or args.output.exists()): |
| 22 | 29 | parser.error('Choose a new output path ending in .app.') |
| 23 | 30 | root = 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. | |
| 35 | TEAM = '9R7DPNW28H' | |
| 36 | BUNDLE_ID = 'net.paperclover.snowbound' | |
| 37 | CONTAINER = 'iCloud.net.paperclover.snowbound' | |
| 38 | ||
| 39 | ||
| 40 | def 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 | ||
| 53 | def 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 | ||
| 66 | def 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 | |
| 24 | 75 | profile = ['--release'] if args.release else [] |
| 25 | 76 | if args.snow_leopard: |
| 26 | 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 | 105 | else: |
| 55 | 106 | icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' |
| 56 | 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. | |
| 109 | build = os.environ.get('SNOWBOUND_BUILD') | |
| 110 | versions = {} | |
| 111 | if build: | |
| 112 | date, revision = build.split('-r') | |
| 113 | versions = {'CFBundleShortVersionString': f'{date} revision {revision}', | |
| 114 | 'CFBundleVersion': f'{date.replace("-", "")}.{revision}'} | |
| 57 | 115 | (bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({ |
| 58 | 116 | 'CFBundleExecutable': binary.name, |
| 59 | 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app', | |
| 117 | 'CFBundleIdentifier': args.bundle_id or BUNDLE_ID, | |
| 60 | 118 | 'CFBundleName': bundle.stem, |
| 61 | 119 | 'CFBundleDisplayName': bundle.stem, |
| 62 | 120 | 'CFBundlePackageType': 'APPL', |
| ... | ... | @@ -64,10 +122,37 @@ else: |
| 64 | 122 | 'NSHighResolutionCapable': True, |
| 65 | 123 | 'NSPrincipalClass': 'NSApplication', |
| 66 | 124 | 'CFBundleIconFile': 'Snowbound', |
| 67 | 'NSMicrophoneUsageDescription': 'Snowbound records audio into your notes when you choose Record Audio.', | |
| 68 | } | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) | |
| 125 | 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.', | |
| 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 | 134 | # 10.6 runs the bundle unsigned. |
| 70 | 135 | if 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 | 157 | subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) |
| 73 | 158 | print(bundle) |
tools/native/custom-art-edit.ps1 created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root) | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $notebook = Join-Path $Root 'notebook' | |
| 5 | $app = New-Object -ComObject OneNote.Application | |
| 6 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 @@ |
| 1 | param([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. | |
| 4 | Set-StrictMode -Version Latest | |
| 5 | $ErrorActionPreference = 'Stop' | |
| 6 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | |
| 7 | Add-Type -AssemblyName PresentationCore, WindowsBase | |
| 8 | $P = [System.Windows.Input.StylusPointProperties] | |
| 9 | $U = [System.Windows.Input.StylusPointPropertyUnit] | |
| 10 | ||
| 11 | function 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). | |
| 17 | function 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 | ||
| 39 | function 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 | ||
| 47 | function 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)) | |
| 57 | for ($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 = @() | |
| 64 | for ($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 | } | |
| 70 | for ($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 = '' | |
| 86 | try { | |
| 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 | |
| 2 | it stores. The oracle for the canvas editor's cross-container deletion. | |
| 3 | ||
| 4 | Usage: native_cross_container.py NOTEBOOK_DIR OUTPUT_DIR | |
| 5 | """ | |
| 6 | import base64 | |
| 7 | import json | |
| 8 | import sys | |
| 9 | from pathlib import Path | |
| 10 | import xml.etree.ElementTree as ET | |
| 11 | ||
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 13 | import native_runner as runner | |
| 14 | from native_xml import ns | |
| 15 | ||
| 16 | windows = 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". | |
| 21 | KEYS = { | |
| 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 | ||
| 29 | def 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) | |
| 38 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 39 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 40 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 41 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 42 | Sleep(3000) | |
| 43 | CoordMode("Mouse", "Screen") | |
| 44 | Click(%d, %d) | |
| 45 | Sleep(500) | |
| 46 | Send("%s") | |
| 47 | Sleep(300) | |
| 48 | Send("+{Right %d}") | |
| 49 | Sleep(500) | |
| 50 | %s | |
| 51 | Sleep(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 | ||
| 64 | if __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 | |
| 2 | the tagged page, and capture what it shows, stores and leaves of the `.snowbound` folder. | |
| 3 | ||
| 4 | Usage: native_custom_art.py NOTEBOOK_DIR OUTPUT_DIR | |
| 5 | """ | |
| 6 | import base64 | |
| 7 | import json | |
| 8 | import sys | |
| 9 | from pathlib import Path | |
| 10 | import xml.etree.ElementTree as ET | |
| 11 | ||
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 13 | import native_runner as runner | |
| 14 | ||
| 15 | windows = runner.windows | |
| 16 | EDIT = Path(__file__).resolve().parent / 'native/custom-art-edit.ps1' | |
| 17 | ||
| 18 | ||
| 19 | def 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 | ||
| 35 | if __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 | |
| 2 | stores. The oracle for the canvas editor's provisional paragraph after an outline's objects. | |
| 3 | ||
| 4 | Usage: native_object_outline.py NOTEBOOK_DIR OUTPUT_DIR | |
| 5 | """ | |
| 6 | import base64 | |
| 7 | import json | |
| 8 | import sys | |
| 9 | from pathlib import Path | |
| 10 | import xml.etree.ElementTree as ET | |
| 11 | ||
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 13 | import native_runner as runner | |
| 14 | from native_xml import ns | |
| 15 | ||
| 16 | windows = runner.windows | |
| 17 | ||
| 18 | # Where a click lands beside each page's object, inside its outline (at 72, 108 pt). | |
| 19 | CLICKS = {'File alone': (236, 301), 'Picture alone': (238, 282)} | |
| 20 | ||
| 21 | ||
| 22 | def 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) | |
| 31 | WinWait("ahk_exe ONENOTE.EXE", , 30) | |
| 32 | WinActivate("ahk_exe ONENOTE.EXE") | |
| 33 | WinWaitActive("ahk_exe ONENOTE.EXE", , 30) | |
| 34 | WinMaximize("ahk_exe ONENOTE.EXE") | |
| 35 | Sleep(3000) | |
| 36 | CoordMode("Mouse", "Screen") | |
| 37 | MouseMove(190, 260) | |
| 38 | Sleep(800) | |
| 39 | Click(%d, %d) | |
| 40 | Sleep(500) | |
| 41 | SendText("abc") | |
| 42 | Sleep(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 | ||
| 55 | if __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.""" | |
| 3 | import argparse | |
| 4 | from datetime import datetime | |
| 5 | import hashlib | |
| 6 | import json | |
| 7 | import os | |
| 8 | from pathlib import Path | |
| 9 | import plistlib | |
| 10 | import shutil | |
| 11 | import subprocess | |
| 12 | import sys | |
| 13 | import tempfile | |
| 14 | from zoneinfo import ZoneInfo | |
| 15 | ||
| 16 | ROOT = Path(__file__).resolve().parents[1] | |
| 17 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') | |
| 18 | URL = 'https://file.paperclover.net/shr/snowbound/' | |
| 19 | KEY = Path.home() / '.config/snowbound/release-key' | |
| 20 | ZONE = ZoneInfo('America/Los_Angeles') | |
| 21 | PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64'] | |
| 22 | CHECKS = [ | |
| 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. | |
| 29 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' | |
| 30 | # The notarytool keychain profile that notarizes it, once `xcrun notarytool store-credentials` makes it. | |
| 31 | NOTARY = 'snowbound' | |
| 32 | # What hardened runtime needs for Record Audio and Record Video. | |
| 33 | ENTITLEMENTS = { | |
| 34 | 'com.apple.security.device.audio-input': True, | |
| 35 | 'com.apple.security.device.camera': True, | |
| 36 | } | |
| 37 | ||
| 38 | ||
| 39 | def 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 | ||
| 46 | def name(version): | |
| 47 | return f'{version[0]}-r{version[1]}' | |
| 48 | ||
| 49 | ||
| 50 | def folder(version): | |
| 51 | """The build's top-level folder.""" | |
| 52 | return f'{version[0]}.r{version[1]}' | |
| 53 | ||
| 54 | ||
| 55 | def parse(text): | |
| 56 | date, revision = text.split('-r') | |
| 57 | return date, int(revision) | |
| 58 | ||
| 59 | ||
| 60 | def 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 | ||
| 67 | def jj(*args): | |
| 68 | return subprocess.check_output(['jj', *args], cwd=ROOT, text=True) | |
| 69 | ||
| 70 | ||
| 71 | def 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 | ||
| 80 | def run(command, **kwargs): | |
| 81 | print('+', ' '.join(map(str, command)), flush=True) | |
| 82 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) | |
| 83 | ||
| 84 | ||
| 85 | def 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 | ||
| 90 | def 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 | ||
| 97 | def zip_bundle(bundle, archive): | |
| 98 | run(['ditto', '-c', '-k', '--norsrc', '--noextattr', '--noqtn', '--noacl', '--keepParent', bundle, archive]) | |
| 99 | ||
| 100 | ||
| 101 | def 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 | ||
| 107 | def 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 | ||
| 127 | def 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 | ||
| 133 | def 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 | ||
| 225 | if __name__ == '__main__': | |
| 226 | main() |
tools/test_cross_container.py created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | from pathlib import Path | |
| 2 | import re | |
| 3 | import runpy | |
| 4 | import shutil | |
| 5 | from tempfile import TemporaryDirectory | |
| 6 | import unittest | |
| 7 | import xml.etree.ElementTree as ET | |
| 8 | ||
| 9 | from native_xml import ns | |
| 10 | ||
| 11 | ROOT = Path(__file__).resolve().parent.parent | |
| 12 | FIXTURE = ROOT / 'corpus/cross-container' | |
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 14 | ||
| 15 | ||
| 16 | def 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 | ||
| 35 | class 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 | ||
| 48 | if __name__ == '__main__': | |
| 49 | unittest.main() |
tools/test_custom_art.py created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | from pathlib import Path | |
| 2 | import hashlib | |
| 3 | import json | |
| 4 | import runpy | |
| 5 | import shutil | |
| 6 | from tempfile import TemporaryDirectory | |
| 7 | import unittest | |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | ||
| 10 | from native_xml import ns | |
| 11 | ||
| 12 | ROOT = Path(__file__).resolve().parent.parent | |
| 13 | FIXTURE = ROOT / 'corpus/custom-art' | |
| 14 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 15 | ||
| 16 | ||
| 17 | def 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 | ||
| 26 | class 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 | ||
| 60 | if __name__ == '__main__': | |
| 61 | unittest.main() |
tools/test_equation_join.py created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | from pathlib import Path | |
| 2 | import re | |
| 3 | import runpy | |
| 4 | import shutil | |
| 5 | from tempfile import TemporaryDirectory | |
| 6 | import unittest | |
| 7 | ||
| 8 | ROOT = Path(__file__).resolve().parent.parent | |
| 9 | FIXTURE = ROOT / 'corpus/equation-join' | |
| 10 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 11 | ||
| 12 | ||
| 13 | def 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 | ||
| 19 | class 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 | ||
| 32 | if __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 | |
| 2 | exports: every point of a stroke is a round stamp whose diameter is the pen's width times | |
| 3 | 0.25 + 1.5 * pressure.""" | |
| 4 | from collections import defaultdict | |
| 5 | from pathlib import Path | |
| 6 | import re | |
| 7 | import runpy | |
| 8 | import shutil | |
| 9 | from tempfile import TemporaryDirectory | |
| 10 | import unittest | |
| 11 | import xml.etree.ElementTree as ET | |
| 12 | import zlib | |
| 13 | ||
| 14 | from native_xml import ns | |
| 15 | ||
| 16 | ROOT = Path(__file__).resolve().parent.parent | |
| 17 | FIXTURE = ROOT / 'corpus/ink-pressure' | |
| 18 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 19 | NUMBER = r'(-?[\d.]+)' | |
| 20 | STAMP = 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 | ||
| 24 | def 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 | ||
| 33 | def 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 | ||
| 46 | def 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 | ||
| 54 | def thickness(pressure): | |
| 55 | return 0.25 + 1.5 * pressure | |
| 56 | ||
| 57 | ||
| 58 | class 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 | ||
| 111 | if __name__ == '__main__': | |
| 112 | unittest.main() |
tools/test_object_outline.py created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | from pathlib import Path | |
| 2 | import re | |
| 3 | import runpy | |
| 4 | import shutil | |
| 5 | from tempfile import TemporaryDirectory | |
| 6 | import unittest | |
| 7 | import xml.etree.ElementTree as ET | |
| 8 | ||
| 9 | from native_xml import ns | |
| 10 | ||
| 11 | ROOT = Path(__file__).resolve().parent.parent | |
| 12 | FIXTURE = ROOT / 'corpus/object-outline' | |
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 14 | ||
| 15 | ||
| 16 | def 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 | ||
| 33 | class 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 | ||
| 46 | if __name__ == '__main__': | |
| 47 | unittest.main() |
tools/test_printout.py created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/printout' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ||
| 25 | class 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 | ||
| 40 | if __name__ == '__main__': | |
| 41 | unittest.main() |
tools/test_recording.py+38| ... | ... | @@ -70,6 +70,44 @@ class RecordingTest(unittest.TestCase): |
| 70 | 70 | self.assertEqual(int.from_bytes(data[20:22], 'little'), 0x11) |
| 71 | 71 | self.assertEqual(int.from_bytes(data[24:28], 'little'), 16000) |
| 72 | 72 | |
| 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 | 111 | def test_onenote_s_recording_opens_as_recorded_after_snowbound_deletes_restores_and_links(self): |
| 74 | 112 | read = self.cold('edit') |
| 75 | 113 | _, playlist, links = recorded(read) |
tools/test_release.py created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | from datetime import datetime, timezone | |
| 2 | from pathlib import Path | |
| 3 | import runpy | |
| 4 | import unittest | |
| 5 | ||
| 6 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) | |
| 7 | ||
| 8 | ||
| 9 | def utc(text): | |
| 10 | return datetime.fromisoformat(text).replace(tzinfo=timezone.utc) | |
| 11 | ||
| 12 | ||
| 13 | class 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 | ||
| 33 | if __name__ == '__main__': | |
| 34 | unittest.main() |
tools/test_table_tags.py created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/table-tags' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ||
| 31 | class 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 | ||
| 46 | if __name__ == '__main__': | |
| 47 | unittest.main() |
tools/verify-document.py+10| ... | ... | @@ -9,6 +9,7 @@ from PIL import Image |
| 9 | 9 | from datetime import datetime, timezone |
| 10 | 10 | import json |
| 11 | 11 | import math |
| 12 | import re | |
| 12 | 13 | from pathlib import Path, PureWindowsPath |
| 13 | 14 | from zoneinfo import ZoneInfo |
| 14 | 15 | import subprocess |
| ... | ... | @@ -156,6 +157,12 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a |
| 156 | 157 | data = assets[json.dumps(container['reference'], sort_keys=True)] |
| 157 | 158 | if kind['type'] == 'Image': |
| 158 | 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 | 166 | if data != expected: |
| 160 | 167 | actual_image = Image.open(BytesIO(data)).convert('RGBA') |
| 161 | 168 | expected_image = Image.open(BytesIO(expected)).convert('RGBA') |
| ... | ... | @@ -256,6 +263,9 @@ def exported_links(runs): |
| 256 | 263 | over http, and URL text it finds in a page's text is a link, a removed link's included |
| 257 | 264 | (`corpus/link-edit/native-typed`).""" |
| 258 | 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 | 269 | if link is None or ':' in link.split('/')[0] or link.startswith('\\\\'): |
| 260 | 270 | return link |
| 261 | 271 | return 'http://' + link |