| author | |
| committer | |
| log | f68c9103fa67fd96322bb45c6ae66086d53bc18d |
| tree | a2a1a47eb03e541c67f1ae798b82d3e6e13800c0 |
| parent | 932ef789ec44c35ed71fda1ec07adcd7365eb416 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- Password-protected sections: unlock, protect, change and remove passwords, lock all, auto-lock
- Notebook Recycle Bin view with restore and empty
- Unread changes in bold with Mark as Read and Next Unread
- Save As for pages, sections and notebooks, and .onepkg pack and unpack
- Direct3D 11 renderer for Windows 7 and machines without Direct3D 12
- Windows 7 Aero toolbar styles (bar, pills, frost, tint, tint-tiles)
- Section colour, notebook properties and default font colour
- Right-to-left pages open where OneNote puts them
- Update summaries listing what each update brings
- Sync status popup with Work Offline in its header
- iOS soft axis lock while scrolling and a zoom detent at 100%
- Single-instance handoff on Windows and Linux
- iOS no longer seeds a sample notebook on first launch
- Search finds text from templates, restored versions and copied pages
- PDF export follows OneNote order and skips date-only untitled pages
- A whole-page paste followed by a sync refresh no longer crashes
- Another device drops its plaintext copy once a section is protected
- Setting a password never overwrites a concurrent revision
- Local CI gate (tools/ci.py) used by releases
Assisted-by: claude-opus-5.5441 files changed, 15917 insertions(+), 4456 deletions(-)
Cargo.lock+69| ... | ... | @@ -563,6 +563,12 @@ dependencies = [ |
| 563 | 563 | "syn 3.0.5", |
| 564 | 564 | ] |
| 565 | 565 | |
| 566 | [[package]] | |
| 567 | name = "byteorder" | |
| 568 | version = "1.5.0" | |
| 569 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 570 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 571 | ||
| 566 | 572 | [[package]] |
| 567 | 573 | name = "byteorder-lite" |
| 568 | 574 | version = "0.1.0" |
| ... | ... | @@ -575,6 +581,18 @@ version = "1.12.1" |
| 575 | 581 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 576 | 582 | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" |
| 577 | 583 | |
| 584 | [[package]] | |
| 585 | name = "cab" | |
| 586 | version = "0.6.0" | |
| 587 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 588 | checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2" | |
| 589 | dependencies = [ | |
| 590 | "byteorder", | |
| 591 | "flate2", | |
| 592 | "lzxd", | |
| 593 | "time", | |
| 594 | ] | |
| 595 | ||
| 578 | 596 | [[package]] |
| 579 | 597 | name = "calloop" |
| 580 | 598 | version = "0.13.0" |
| ... | ... | @@ -900,6 +918,12 @@ dependencies = [ |
| 900 | 918 | "hybrid-array", |
| 901 | 919 | ] |
| 902 | 920 | |
| 921 | [[package]] | |
| 922 | name = "deranged" | |
| 923 | version = "0.5.8" | |
| 924 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 925 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 926 | ||
| 903 | 927 | [[package]] |
| 904 | 928 | name = "digest" |
| 905 | 929 | version = "0.11.3" |
| ... | ... | @@ -987,6 +1011,7 @@ dependencies = [ |
| 987 | 1011 | "subsetter", |
| 988 | 1012 | "swash", |
| 989 | 1013 | "wgpu", |
| 1014 | "windows", | |
| 990 | 1015 | ] |
| 991 | 1016 | |
| 992 | 1017 | [[package]] |
| ... | ... | @@ -1849,6 +1874,12 @@ dependencies = [ |
| 1849 | 1874 | "twox-hash", |
| 1850 | 1875 | ] |
| 1851 | 1876 | |
| 1877 | [[package]] | |
| 1878 | name = "lzxd" | |
| 1879 | version = "0.2.7" | |
| 1880 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1881 | checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168" | |
| 1882 | ||
| 1852 | 1883 | [[package]] |
| 1853 | 1884 | name = "md-5" |
| 1854 | 1885 | version = "0.11.0" |
| ... | ... | @@ -1946,6 +1977,7 @@ dependencies = [ |
| 1946 | 1977 | "serde_json", |
| 1947 | 1978 | "tempfile", |
| 1948 | 1979 | "wgpu", |
| 1980 | "zeroize", | |
| 1949 | 1981 | ] |
| 1950 | 1982 | |
| 1951 | 1983 | [[package]] |
| ... | ... | @@ -2042,6 +2074,11 @@ dependencies = [ |
| 2042 | 2074 | name = "notebook" |
| 2043 | 2075 | version = "0.1.0" |
| 2044 | 2076 | dependencies = [ |
| 2077 | "aes-gcm", | |
| 2078 | "base64 0.22.1", | |
| 2079 | "cab", | |
| 2080 | "getrandom 0.4.3", | |
| 2081 | "hmac", | |
| 2045 | 2082 | "libc", |
| 2046 | 2083 | "nix", |
| 2047 | 2084 | "onestore", |
| ... | ... | @@ -2067,6 +2104,12 @@ dependencies = [ |
| 2067 | 2104 | "unicode-segmentation", |
| 2068 | 2105 | ] |
| 2069 | 2106 | |
| 2107 | [[package]] | |
| 2108 | name = "num-conv" | |
| 2109 | version = "0.2.2" | |
| 2110 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2111 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 2112 | ||
| 2070 | 2113 | [[package]] |
| 2071 | 2114 | name = "num-traits" |
| 2072 | 2115 | version = "0.2.19" |
| ... | ... | @@ -2747,6 +2790,12 @@ dependencies = [ |
| 2747 | 2790 | "zerovec", |
| 2748 | 2791 | ] |
| 2749 | 2792 | |
| 2793 | [[package]] | |
| 2794 | name = "powerfmt" | |
| 2795 | version = "0.2.0" | |
| 2796 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2797 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 2798 | ||
| 2750 | 2799 | [[package]] |
| 2751 | 2800 | name = "presser" |
| 2752 | 2801 | version = "0.3.1" |
| ... | ... | @@ -3392,6 +3441,7 @@ dependencies = [ |
| 3392 | 3441 | "windows-sys 0.61.2", |
| 3393 | 3442 | "winit", |
| 3394 | 3443 | "zbus", |
| 3444 | "zeroize", | |
| 3395 | 3445 | ] |
| 3396 | 3446 | |
| 3397 | 3447 | [[package]] |
| ... | ... | @@ -3591,6 +3641,25 @@ dependencies = [ |
| 3591 | 3641 | "zune-jpeg", |
| 3592 | 3642 | ] |
| 3593 | 3643 | |
| 3644 | [[package]] | |
| 3645 | name = "time" | |
| 3646 | version = "0.3.55" | |
| 3647 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3648 | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 3649 | dependencies = [ | |
| 3650 | "deranged", | |
| 3651 | "num-conv", | |
| 3652 | "powerfmt", | |
| 3653 | "serde_core", | |
| 3654 | "time-core", | |
| 3655 | ] | |
| 3656 | ||
| 3657 | [[package]] | |
| 3658 | name = "time-core" | |
| 3659 | version = "0.1.9" | |
| 3660 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3661 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 3662 | ||
| 3594 | 3663 | [[package]] |
| 3595 | 3664 | name = "tiny-skia" |
| 3596 | 3665 | version = "0.11.4" |
apps/ios/Snowbound.xcodeproj/project.pbxproj-4| ... | ... | @@ -7,14 +7,12 @@ |
| 7 | 7 | 	objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | 		5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; | |
| 11 | 10 | 		5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; |
| 12 | 11 | 		5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; |
| 13 | 12 | /* End PBXBuildFile section */ |
| 14 | 13 | |
| 15 | 14 | /* Begin PBXFileReference section */ |
| 16 | 15 | 		5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 17 | 		5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; | |
| 18 | 16 | 		5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; |
| 19 | 17 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
| 20 | 18 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; |
| ... | ... | @@ -45,7 +43,6 @@ |
| 45 | 43 | 			isa = PBXGroup; |
| 46 | 44 | 			children = ( |
| 47 | 45 | 				5B0000000000000000000300 /* Snowbound */, |
| 48 | 				5B0000000000000000000201 /* Sample */, | |
| 49 | 46 | 				5B0000000000000000000226 /* Snowbound Guide */, |
| 50 | 47 | 				5B0000000000000000000205 /* Snowbound-Tahoe.icon */, |
| 51 | 48 | 				5B0000000000000000000202 /* Info.plist */, |
| ... | ... | @@ -126,7 +123,6 @@ |
| 126 | 123 | 			isa = PBXResourcesBuildPhase; |
| 127 | 124 | 			buildActionMask = 2147483647; |
| 128 | 125 | 			files = ( |
| 129 | 				5B0000000000000000000101 /* Sample in Resources */, | |
| 130 | 126 | 				5B0000000000000000000126 /* Snowbound Guide in Resources */, |
| 131 | 127 | 				5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */, |
| 132 | 128 | 			); |
apps/ios/Snowbound/App.swift+16-5| ... | ... | @@ -150,6 +150,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 150 | 150 | private let notebooks = NotebooksViewController() |
| 151 | 151 | private let pages = PagesViewController() |
| 152 | 152 | private var showingPage = false |
| 153 | /// A first launch opened a page by itself. | |
| 154 | private var landed = false | |
| 153 | 155 | private var background = UIBackgroundTaskIdentifier.invalid |
| 154 | 156 | |
| 155 | 157 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { |
| ... | ... | @@ -203,8 +205,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 203 | 205 | } |
| 204 | 206 | return |
| 205 | 207 | } |
| 206 | guard let place = Place.saved, place.notebook == notebook.id, | |
| 207 | let tab = notebook.tabs.first(where: { $0.path == place.section }) | |
| 208 | guard let place = Place.saved else { | |
| 209 | // The first notebook found opens at its first page, as Notes opens on a note. | |
| 210 | guard Prototype.welcome, !landed, let tab = notebook.tabs.first(where: \.readable) else { return } | |
| 211 | landed = true | |
| 212 | return open(tab, of: notebook) { [weak self] section in | |
| 213 | if let page = section.rows.first { self?.show(section: section, page: page.id) } | |
| 214 | } | |
| 215 | } | |
| 216 | guard place.notebook == notebook.id, let tab = notebook.tabs.first(where: { $0.path == place.section }) | |
| 208 | 217 | else { return } |
| 209 | 218 | open(tab, of: notebook, page: place.page) |
| 210 | 219 | } |
| ... | ... | @@ -213,7 +222,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 213 | 222 | _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil, |
| 214 | 223 | then: ((Section) -> Void)? = nil |
| 215 | 224 | ) { |
| 216 | guard Author.name != nil else { | |
| 225 | // The welcome prototype asks at the first edit instead. | |
| 226 | guard Author.name != nil || Prototype.welcome else { | |
| 217 | 227 | return Author.ask(from: split) { [weak self] in |
| 218 | 228 | self?.open(tab, of: notebook, page: page, reveal: reveal, then: then) |
| 219 | 229 | } |
| ... | ... | @@ -223,7 +233,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 223 | 233 | Section.open(tab, of: notebook) { [weak self] section, problem in |
| 224 | 234 | guard let self else { return } |
| 225 | 235 | guard let section else { |
| 226 | pages.failed(tab, problem) | |
| 236 | pages.failed(problem) | |
| 227 | 237 | return |
| 228 | 238 | } |
| 229 | 239 | pages.load(section) |
| ... | ... | @@ -300,7 +310,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 300 | 310 | /// Composition ends and every edit is stored and published while the system allows. |
| 301 | 311 | func sceneDidEnterBackground(_ scene: UIScene) { |
| 302 | 312 | NotificationCenter.default.post(name: PageViewController.leaving, object: nil) |
| 303 | for notebook in Notebooks.all { notebook.pause() } | |
| 313 | // Protected sections open again only once unlocked. | |
| 314 | for notebook in Notebooks.all { notebook.pause(); notebook.lockAll() } | |
| 304 | 315 | let sections = Section.all |
| 305 | 316 | guard !sections.isEmpty, background == .invalid else { return } |
| 306 | 317 | background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in |
apps/ios/Snowbound/CanvasView.swift+154-30| ... | ... | @@ -81,6 +81,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 81 | 81 | private var origin = CGPoint.zero |
| 82 | 82 | private var syncing = false |
| 83 | 83 | private var scrolled = false |
| 84 | private var drag: Drag? | |
| 85 | /// The zoom and finger spread a pinch started from. | |
| 86 | private var pinchStart: (zoom: CGFloat, spread: CGFloat)? | |
| 87 | private var inDetent = false | |
| 88 | private let detentTick = UISelectionFeedbackGenerator() | |
| 84 | 89 | /// How far right of the content's corner a page opens. |
| 85 | 90 | private var left: CGFloat = 0 |
| 86 | 91 | /// The zoom a double tap returns to. |
| ... | ... | @@ -230,7 +235,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 230 | 235 | paper() |
| 231 | 236 | sync() |
| 232 | 237 | let skip = margin |
| 233 | resting = openingZoom() | |
| 238 | resting = openingZoom(skipping: skip) | |
| 234 | 239 | zoomScale = resting |
| 235 | 240 | left = skip * resting |
| 236 | 241 | home() |
| ... | ... | @@ -301,12 +306,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 301 | 306 | dirty = true |
| 302 | 307 | } |
| 303 | 308 | |
| 304 | /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's | |
| 305 | /// 11 pt body text shows at 17 points. | |
| 306 | private func openingZoom() -> CGFloat { | |
| 307 | guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 } | |
| 308 | // A page opens readable however far the last one was pinched. | |
| 309 | return min(2, max(0.75, zoom)) | |
| 309 | /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's | |
| 310 | /// 11 pt body text shows at 17 points; less where that fits the page right of `skip` | |
| 311 | /// across the screen. | |
| 312 | private func openingZoom(skipping skip: CGFloat) -> CGFloat { | |
| 313 | let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1 | |
| 314 | let fit = bounds.inset(by: safeAreaInsets).width / max(1, content.bounds.width - skip) | |
| 315 | // A page opens readable however far the last one was pinched or however wide it is. | |
| 316 | return max(0.75, min(2, pinched, fit)) | |
| 310 | 317 | } |
| 311 | 318 | |
| 312 | 319 | /// The empty page left of its first outline, which a phone skips on opening, keeping |
| ... | ... | @@ -329,22 +336,114 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 329 | 336 | if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } |
| 330 | 337 | } |
| 331 | 338 | |
| 332 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } | |
| 333 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true } | |
| 339 | /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the | |
| 340 | /// finger more than 40° off it lets go, and the page catches up over the next 48 points. | |
| 341 | private struct Drag { | |
| 342 | static let snap = tan(CGFloat.pi / 6) | |
| 343 | static let release = tan(CGFloat.pi * 2 / 9) | |
| 344 | static let stretch: CGFloat = 24 | |
| 345 | static let catchUp: CGFloat = 48 | |
| 346 | ||
| 347 | /// The axis the lock holds still. | |
| 348 | let cross: WritableKeyPath<CGPoint, CGFloat> | |
| 349 | var locked = true | |
| 350 | /// The offset last shown, from which the scroll view's next step is measured. | |
| 351 | var offset: CGPoint | |
| 352 | /// The translation where the stretch of the gesture being judged began. | |
| 353 | var stretch: CGPoint | |
| 354 | /// How far the held axis trails the finger. | |
| 355 | var lag: CGFloat = 0 | |
| 356 | } | |
| 357 | ||
| 358 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { | |
| 359 | scrolled = true | |
| 360 | let moved = panGestureRecognizer.translation(in: self) | |
| 361 | let cross: WritableKeyPath<CGPoint, CGFloat>? = | |
| 362 | abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil | |
| 363 | drag = cross.map { Drag(cross: $0, offset: contentOffset, stretch: moved) } | |
| 364 | } | |
| 365 | ||
| 366 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { | |
| 367 | scrolled = true | |
| 368 | drag = nil | |
| 369 | pinchStart = spread.map { (zoomScale, $0) } | |
| 370 | inDetent = abs(log(zoomScale)) < Self.detent | |
| 371 | detentTick.prepare() | |
| 372 | } | |
| 373 | ||
| 334 | 374 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } |
| 335 | func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() } | |
| 375 | ||
| 376 | func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
| 377 | if var drag, isDragging || isDecelerating, !isZooming { | |
| 378 | let cross = drag.cross | |
| 379 | let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x | |
| 380 | var offset = contentOffset | |
| 381 | let step = offset[keyPath: cross] - drag.offset[keyPath: cross] | |
| 382 | if drag.locked { | |
| 383 | drag.lag += step | |
| 384 | offset[keyPath: cross] = drag.offset[keyPath: cross] | |
| 385 | let moved = panGestureRecognizer.translation(in: self) | |
| 386 | let stretch = CGPoint(x: moved.x - drag.stretch.x, y: moved.y - drag.stretch.y) | |
| 387 | if isTracking, hypot(stretch.x, stretch.y) >= Drag.stretch { | |
| 388 | drag.locked = abs(stretch[keyPath: cross]) <= abs(stretch[keyPath: along]) * Drag.release | |
| 389 | drag.stretch = moved | |
| 390 | } | |
| 391 | } else { | |
| 392 | let travel = hypot(step, offset[keyPath: along] - drag.offset[keyPath: along]) | |
| 393 | let paid = drag.lag * (1 - exp(-travel / Drag.catchUp)) | |
| 394 | offset[keyPath: cross] += paid | |
| 395 | drag.lag -= paid | |
| 396 | } | |
| 397 | drag.offset = offset | |
| 398 | self.drag = drag.locked || abs(drag.lag) >= 0.5 ? drag : nil | |
| 399 | if offset != contentOffset { contentOffset = offset } | |
| 400 | } | |
| 401 | transform() | |
| 402 | } | |
| 403 | ||
| 404 | /// How far apart a pinch's two fingers are. | |
| 405 | private var spread: CGFloat? { | |
| 406 | guard let pinch = pinchGestureRecognizer, pinch.numberOfTouches == 2 else { return nil } | |
| 407 | let a = pinch.location(ofTouch: 0, in: self), b = pinch.location(ofTouch: 1, in: self) | |
| 408 | return hypot(a.x - b.x, a.y - b.y) | |
| 409 | } | |
| 410 | ||
| 411 | /// How far either side of 100% a pinch sticks, as a log of the zoom. | |
| 412 | private static let detent = log(CGFloat(1.1)) | |
| 413 | ||
| 414 | /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers | |
| 415 | /// on a curve flat at 100% that meets them again at the band's edges. | |
| 336 | 416 | func scrollViewDidZoom(_ scrollView: UIScrollView) { |
| 417 | if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread { | |
| 418 | // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one. | |
| 419 | let raw = log(pinchStart.zoom * spread / pinchStart.spread) | |
| 420 | let x = abs(raw) / Self.detent | |
| 421 | if x < 1 { | |
| 422 | if !inDetent { detentTick.selectionChanged() } | |
| 423 | let shown = exp(Self.detent * x * x * (2 - x) * (raw < 0 ? -1 : 1)) | |
| 424 | if abs(shown - zoomScale) > 0.0001 { | |
| 425 | let focus = pinch.location(in: content) | |
| 426 | let screen = CGPoint(x: pinch.location(in: self).x - contentOffset.x, y: pinch.location(in: self).y - contentOffset.y) | |
| 427 | zoomScale = shown | |
| 428 | contentOffset = CGPoint(x: focus.x * shown - screen.x, y: focus.y * shown - screen.y) | |
| 429 | } | |
| 430 | } | |
| 431 | inDetent = x < 1 | |
| 432 | } | |
| 337 | 433 | transform() |
| 338 | 434 | // Scrolling carries the caret and handles along; zooming moves them within the page. |
| 339 | 435 | display.setNeedsSelectionUpdate() |
| 340 | 436 | } |
| 341 | 437 | |
| 342 | 438 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { |
| 439 | pinchStart = nil | |
| 343 | 440 | if tapZooming { |
| 344 | 441 | tapZooming = false |
| 345 | 442 | } else { |
| 346 | resting = scale | |
| 347 | UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey) | |
| 443 | // A pinch let go within the detent settles on 100%. | |
| 444 | resting = abs(log(scale)) < Self.detent ? 1 : scale | |
| 445 | if resting != scale { setZoomScale(resting, animated: true) } | |
| 446 | UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey) | |
| 348 | 447 | } |
| 349 | 448 | } |
| 350 | 449 | |
| ... | ... | @@ -694,8 +793,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 694 | 793 | edit(external: true) { sb_delete_backward(handle) } |
| 695 | 794 | } |
| 696 | 795 | |
| 697 | /// Runs a change to the page, telling the system when it did not ask for it. | |
| 698 | private func edit(external: Bool = false, _ change: () -> Bool) { | |
| 796 | /// Runs a change to the page, telling the system when it did not ask for it; returns | |
| 797 | /// whether it changed. | |
| 798 | @discardableResult | |
| 799 | private func edit(external: Bool = false, _ change: () -> Bool) -> Bool { | |
| 699 | 800 | if external { |
| 700 | 801 | inputDelegate?.selectionWillChange(self) |
| 701 | 802 | inputDelegate?.textWillChange(self) |
| ... | ... | @@ -712,6 +813,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 712 | 813 | } |
| 713 | 814 | display.setNeedsSelectionUpdate() |
| 714 | 815 | changedSelection() |
| 816 | return changed | |
| 715 | 817 | } |
| 716 | 818 | |
| 717 | 819 | /// Tells the bar buttons and the format bar what the selection now has. |
| ... | ... | @@ -736,6 +838,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 736 | 838 | override var inputAccessoryView: UIView? { formatBar } |
| 737 | 839 | |
| 738 | 840 | override func becomeFirstResponder() -> Bool { |
| 841 | if Prototype.welcome, Author.name == nil, let controller = window?.rootViewController { | |
| 842 | Prototype.askName(from: controller) { [weak self] in _ = self?.becomeFirstResponder() } | |
| 843 | return false | |
| 844 | } | |
| 739 | 845 | guard super.becomeFirstResponder() else { return false } |
| 740 | 846 | if let handle { sb_view_focus(handle, true) } |
| 741 | 847 | display.isActivated = true |
| ... | ... | @@ -876,6 +982,37 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 876 | 982 | /// A conflict page, which shows what is stored and takes no edits. |
| 877 | 983 | var readOnly: Bool { handle.map(sb_view_read_only) ?? false } |
| 878 | 984 | |
| 985 | /// The column a reading view fills: the screen's width less the room tags hang in. | |
| 986 | private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) } | |
| 987 | ||
| 988 | /// Whether the page has a reading view here, and how it reads or why not. | |
| 989 | var reading: (offered: Bool, verdict: String)? { | |
| 990 | struct Reading: Decodable { | |
| 991 | let offered: Bool | |
| 992 | let verdict: String | |
| 993 | } | |
| 994 | guard let handle, let read = decode(Reading.self, sb_view_reading(handle, readingWidth)) else { return nil } | |
| 995 | return (read.offered, read.verdict) | |
| 996 | } | |
| 997 | ||
| 998 | /// Whether the page shows reflowed, as `showReading` left it. | |
| 999 | private(set) var readingShown = false | |
| 1000 | ||
| 1001 | /// Shows the page reflowed into the screen's width at 100%, read-only, or as laid out. | |
| 1002 | func showReading(_ on: Bool) { | |
| 1003 | guard let handle else { return } | |
| 1004 | _ = resignFirstResponder() | |
| 1005 | readingShown = sb_view_set_reading(handle, on ? readingWidth : 0) | |
| 1006 | zoomScale = 1 | |
| 1007 | resting = 1 | |
| 1008 | scrolled = false | |
| 1009 | sync() | |
| 1010 | left = margin | |
| 1011 | home() | |
| 1012 | dirty = true | |
| 1013 | onChange?() | |
| 1014 | } | |
| 1015 | ||
| 879 | 1016 | /// The title as typed; nil on a page without an editable title. |
| 880 | 1017 | var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } |
| 881 | 1018 | |
| ... | ... | @@ -933,11 +1070,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 933 | 1070 | /// Selects paragraph `id` and brings it into view. |
| 934 | 1071 | func selectParagraph(_ id: String) -> Bool { |
| 935 | 1072 | guard let handle else { return false } |
| 936 | var found = false | |
| 937 | edit(external: true) { | |
| 938 | found = sb_view_select_paragraph(handle, id) | |
| 939 | return found | |
| 940 | } | |
| 1073 | let found = edit(external: true) { sb_view_select_paragraph(handle, id) } | |
| 941 | 1074 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } |
| 942 | 1075 | return found |
| 943 | 1076 | } |
| ... | ... | @@ -966,11 +1099,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 966 | 1099 | /// Selects the first place `query` occurs and brings it into view. |
| 967 | 1100 | func find(_ query: String) -> Bool { |
| 968 | 1101 | guard let handle else { return false } |
| 969 | var found = false | |
| 970 | edit(external: true) { | |
| 971 | found = sb_view_find(handle, query) | |
| 972 | return found | |
| 973 | } | |
| 1102 | let found = edit(external: true) { sb_view_find(handle, query) } | |
| 974 | 1103 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } |
| 975 | 1104 | return found |
| 976 | 1105 | } |
| ... | ... | @@ -978,12 +1107,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 978 | 1107 | /// Puts the caret at the end of the page title. |
| 979 | 1108 | func focusTitle() -> Bool { |
| 980 | 1109 | guard let handle else { return false } |
| 981 | var focused = false | |
| 982 | edit(external: true) { | |
| 983 | focused = sb_view_focus_title(handle) | |
| 984 | return focused | |
| 985 | } | |
| 986 | return focused | |
| 1110 | return edit(external: true) { sb_view_focus_title(handle) } | |
| 987 | 1111 | } |
| 988 | 1112 | |
| 989 | 1113 | /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most |
apps/ios/Snowbound/ICloud.swift+6| ... | ... | @@ -78,6 +78,12 @@ enum ICloud { |
| 78 | 78 | /// Looks the folder up off the main thread, as asking iCloud can take a while. |
| 79 | 79 | static func lookUp() { |
| 80 | 80 | background({ () -> URL? in |
| 81 | #if DEBUG | |
| 82 | // A simulator signs into no account; `SNOWBOUND_ICLOUD` names a folder standing in. | |
| 83 | if let folder = ProcessInfo.processInfo.environment["SNOWBOUND_ICLOUD"] { | |
| 84 | return URL(fileURLWithPath: folder, isDirectory: true) | |
| 85 | } | |
| 86 | #endif | |
| 81 | 87 | guard |
| 82 | 88 | let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? |
| 83 | 89 | .appendingPathComponent("Documents", isDirectory: true) |
apps/ios/Snowbound/Lists.swift+102-39| ... | ... | @@ -133,6 +133,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 133 | 133 | |
| 134 | 134 | func reload() { |
| 135 | 135 | guard isViewLoaded else { return } |
| 136 | if Prototype.welcome { | |
| 137 | let welcome = Prototype.welcome( | |
| 138 | new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) }, | |
| 139 | openFolder: { [weak self] in self?.openFolder() }, connect: { [weak self] in self?.connect() }) | |
| 140 | contentUnavailableConfiguration = welcome | |
| 141 | if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) } | |
| 142 | } | |
| 136 | 143 | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } |
| 137 | 144 | let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] |
| 138 | 145 | let cloud: [(Location, [Notebook], [Item])] = |
| ... | ... | @@ -186,6 +193,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 186 | 193 | reload() |
| 187 | 194 | } |
| 188 | 195 | |
| 196 | /// Lists the notebooks again and opens the first section of the one from `source`. | |
| 197 | private func rescan(opening source: Source) { | |
| 198 | rescan { [weak self] notebook in | |
| 199 | guard notebook.source == source, let tab = notebook.tabs.first(where: \.readable) else { return } | |
| 200 | self?.onOpen?(tab, notebook) | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 189 | 204 | private func showOnDevice(_ shown: Bool) { |
| 190 | 205 | Notebooks.showsOnDevice = shown |
| 191 | 206 | rescan() |
| ... | ... | @@ -239,7 +254,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 239 | 254 | content.image = UIImage( |
| 240 | 255 | systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill") |
| 241 | 256 | content.imageProperties.tintColor = tab.uiColor |
| 242 | if !tab.readable { | |
| 257 | if tab.locked { | |
| 258 | content.secondaryText = "Password protected" | |
| 259 | } else if !tab.readable { | |
| 243 | 260 | content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here" |
| 244 | 261 | content.textProperties.color = .secondaryLabel |
| 245 | 262 | } |
| ... | ... | @@ -370,7 +387,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 370 | 387 | return closed ? nil : problem ?? "" |
| 371 | 388 | }) { [weak self] problem in |
| 372 | 389 | if let problem { |
| 373 | self?.refuse(title, problem) | |
| 390 | self?.alert(title, problem) | |
| 374 | 391 | return |
| 375 | 392 | } |
| 376 | 393 | notebook.pause() |
| ... | ... | @@ -387,7 +404,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 387 | 404 | isFile ? name + "." + folder.pathExtension : name) |
| 388 | 405 | guard target != folder else { return } |
| 389 | 406 | guard !FileManager.default.fileExists(atPath: target.path) else { |
| 390 | self?.refuse("“\(name)” Already Exists", "Choose a different name.") | |
| 407 | self?.alert("“\(name)” Already Exists", "Choose a different name.") | |
| 391 | 408 | return |
| 392 | 409 | } |
| 393 | 410 | self?.close(notebook, refusing: "Can’t Rename") { |
| ... | ... | @@ -395,7 +412,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 395 | 412 | try FileManager.default.moveItem(at: folder, to: target) |
| 396 | 413 | _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil) |
| 397 | 414 | } catch { |
| 398 | self?.refuse("Can’t Rename", error.localizedDescription) | |
| 415 | self?.alert("Can’t Rename", error.localizedDescription) | |
| 399 | 416 | } |
| 400 | 417 | self?.rescan() |
| 401 | 418 | } |
| ... | ... | @@ -406,10 +423,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 406 | 423 | field.clearButtonMode = .whileEditing |
| 407 | 424 | NotificationCenter.default.addObserver( |
| 408 | 425 | forName: UITextField.textDidChangeNotification, object: field, queue: .main |
| 409 | ) { _ in | |
| 410 | let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) | |
| 411 | rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") | |
| 412 | } | |
| 426 | ) { _ in rename.isEnabled = Self.namesFile(field) } | |
| 413 | 427 | } |
| 414 | 428 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 415 | 429 | alert.addAction(rename) |
| ... | ... | @@ -417,11 +431,17 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 417 | 431 | present(alert, animated: true) |
| 418 | 432 | } |
| 419 | 433 | |
| 434 | /// Whether `field` holds a name a notebook's folder can take. | |
| 435 | private static func namesFile(_ field: UITextField) -> Bool { | |
| 436 | let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) | |
| 437 | return !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") | |
| 438 | } | |
| 439 | ||
| 420 | 440 | /// Copies the notebook beside itself, named as Files names a duplicate. |
| 421 | 441 | private func duplicate(_ folder: URL, of notebook: Notebook) { |
| 422 | 442 | // The copy takes what the open sections have stored. |
| 423 | 443 | for section in Section.all where section.notebook === notebook && !section.flush(10) { |
| 424 | return refuse("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.") | |
| 444 | return alert("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.") | |
| 425 | 445 | } |
| 426 | 446 | let base = folder.deletingPathExtension().lastPathComponent |
| 427 | 447 | let ext = folder.pathExtension |
| ... | ... | @@ -436,7 +456,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 436 | 456 | do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription } |
| 437 | 457 | return nil |
| 438 | 458 | }) { [weak self] problem in |
| 439 | if let problem { self?.refuse("Can’t Duplicate", problem) } | |
| 459 | if let problem { self?.alert("Can’t Duplicate", problem) } | |
| 440 | 460 | self?.rescan() |
| 441 | 461 | } |
| 442 | 462 | } |
| ... | ... | @@ -475,7 +495,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 475 | 495 | }) |
| 476 | 496 | alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in |
| 477 | 497 | do { try FileManager.default.removeItem(at: folder) } catch { |
| 478 | self?.refuse("Can’t Delete", error.localizedDescription) | |
| 498 | self?.alert("Can’t Delete", error.localizedDescription) | |
| 479 | 499 | } |
| 480 | 500 | self?.rescan() |
| 481 | 501 | }) |
| ... | ... | @@ -495,7 +515,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 495 | 515 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } |
| 496 | 516 | switch item { |
| 497 | 517 | case .section(let id, let path): |
| 498 | return notebook(id)?.tabs.first(where: { $0.path == path })?.readable == true | |
| 518 | let tab = notebook(id)?.tabs.first(where: { $0.path == path }) | |
| 519 | return tab?.readable == true || tab?.locked == true | |
| 499 | 520 | case .status(let id): |
| 500 | 521 | guard let notebook = notebook(id), notebook.problem != nil else { return false } |
| 501 | 522 | let spinner = UIActivityIndicatorView(style: .medium) |
| ... | ... | @@ -532,9 +553,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 532 | 553 | guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id), |
| 533 | 554 | let tab = notebook.tabs.first(where: { $0.path == path }) |
| 534 | 555 | else { return } |
| 556 | if tab.locked { return unlock(tab, of: notebook, wrong: false) } | |
| 535 | 557 | onOpen?(tab, notebook) |
| 536 | 558 | } |
| 537 | 559 | |
| 560 | /// Asks for a protected section's password, as OneNote's Protected Section dialog does, | |
| 561 | /// and opens it once unlocked. | |
| 562 | private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) { | |
| 563 | let alert = UIAlertController( | |
| 564 | title: "Protected Section", | |
| 565 | message: wrong ? "Password is incorrect." : "Section “\(tab.name)” is password protected.", | |
| 566 | preferredStyle: .alert) | |
| 567 | alert.addTextField { field in | |
| 568 | field.isSecureTextEntry = true | |
| 569 | field.placeholder = "Password" | |
| 570 | } | |
| 571 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) | |
| 572 | alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self, weak alert] _ in | |
| 573 | let password = alert?.textFields?.first?.text ?? "" | |
| 574 | notebook.unlock(tab.path, password: password) { unlocked in | |
| 575 | self?.reload() | |
| 576 | if unlocked, let opened = notebook.tabs.first(where: { $0.path == tab.path }) { | |
| 577 | self?.onOpen?(opened, notebook) | |
| 578 | } else { | |
| 579 | self?.unlock(tab, of: notebook, wrong: true) | |
| 580 | } | |
| 581 | } | |
| 582 | }) | |
| 583 | present(alert, animated: true) | |
| 584 | } | |
| 585 | ||
| 538 | 586 | private func add(_ notebook: Notebook) { |
| 539 | 587 | Notebooks.add(notebook) |
| 540 | 588 | openFirstSection(of: notebook) |
| ... | ... | @@ -572,10 +620,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 572 | 620 | field.autocapitalizationType = .words |
| 573 | 621 | NotificationCenter.default.addObserver( |
| 574 | 622 | forName: UITextField.textDidChangeNotification, object: field, queue: .main |
| 575 | ) { _ in | |
| 576 | let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) | |
| 577 | create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") | |
| 578 | } | |
| 623 | ) { _ in create.isEnabled = Self.namesFile(field) } | |
| 579 | 624 | } |
| 580 | 625 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 581 | 626 | alert.addAction(create) |
| ... | ... | @@ -587,7 +632,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 587 | 632 | let place = inCloud ? ICloud.documents : documentsDirectory |
| 588 | 633 | guard let folder = place?.appendingPathComponent(name) else { return } |
| 589 | 634 | guard !FileManager.default.fileExists(atPath: folder.path) else { |
| 590 | return refuse("“\(name)” Already Exists", "Choose a different name.") | |
| 635 | return alert("“\(name)” Already Exists", "Choose a different name.") | |
| 591 | 636 | } |
| 592 | 637 | let (date, time) = titleDate() |
| 593 | 638 | background({ () -> String? in |
| ... | ... | @@ -597,14 +642,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 597 | 642 | return made ? nil : problem ?? "" |
| 598 | 643 | }) { [weak self] problem in |
| 599 | 644 | guard let self else { return } |
| 600 | if let problem { return refuse("Can’t Create Notebook", problem) } | |
| 645 | if let problem { return alert("Can’t Create Notebook", problem) } | |
| 601 | 646 | if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } |
| 602 | rescan { [weak self] notebook in | |
| 603 | guard notebook.source == (inCloud ? .icloud(path: name) : .documents(path: name)), | |
| 604 | let tab = notebook.tabs.first(where: \.readable) | |
| 605 | else { return } | |
| 606 | self?.onOpen?(tab, notebook) | |
| 607 | } | |
| 647 | rescan(opening: inCloud ? .icloud(path: name) : .documents(path: name)) | |
| 608 | 648 | } |
| 609 | 649 | } |
| 610 | 650 | |
| ... | ... | @@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 621 | 661 | return nil |
| 622 | 662 | }) { [weak self] problem in |
| 623 | 663 | guard let self else { return } |
| 624 | if let problem { return refuse("Can’t Open the Guide", problem) } | |
| 664 | if let problem { return alert("Can’t Open the Guide", problem) } | |
| 625 | 665 | if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } |
| 626 | rescan { [weak self] notebook in | |
| 627 | guard notebook.source == .documents(path: Notebooks.guide), | |
| 628 | let tab = notebook.tabs.first(where: \.readable) | |
| 629 | else { return } | |
| 630 | self?.onOpen?(tab, notebook) | |
| 631 | } | |
| 666 | rescan(opening: .documents(path: Notebooks.guide)) | |
| 632 | 667 | } |
| 633 | 668 | } |
| 634 | 669 | |
| 635 | private func refuse(_ title: String, _ message: String) { | |
| 636 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) | |
| 637 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 638 | present(alert, animated: true) | |
| 639 | } | |
| 640 | ||
| 641 | 670 | private func openFolder() { |
| 642 | 671 | let picker = UIDocumentPickerViewController( |
| 643 | 672 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) |
| ... | ... | @@ -721,6 +750,11 @@ final class PagesViewController: UITableViewController { |
| 721 | 750 | /// Opens a page of the section. |
| 722 | 751 | var onOpen: ((Section, String) -> Void)? |
| 723 | 752 | |
| 753 | /// The section strip prototype, over the pages. | |
| 754 | private lazy var strip = SectionStrip { [weak self] tab, notebook in | |
| 755 | (self?.view.window?.windowScene?.delegate as? SceneDelegate)?.open(tab, of: notebook) | |
| 756 | } | |
| 757 | ||
| 724 | 758 | init() { |
| 725 | 759 | super.init(style: .plain) |
| 726 | 760 | } |
| ... | ... | @@ -740,6 +774,25 @@ final class PagesViewController: UITableViewController { |
| 740 | 774 | ] |
| 741 | 775 | NotificationCenter.default.addObserver( |
| 742 | 776 | self, selector: #selector(changed), name: Section.changed, object: nil) |
| 777 | if Prototype.sectionStrip { | |
| 778 | for direction in [UISwipeGestureRecognizer.Direction.left, .right] { | |
| 779 | let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped)) | |
| 780 | swipe.direction = direction | |
| 781 | tableView.addGestureRecognizer(swipe) | |
| 782 | } | |
| 783 | } | |
| 784 | } | |
| 785 | ||
| 786 | @objc private func swiped(_ swipe: UISwipeGestureRecognizer) { | |
| 787 | if let tab = strip.neighbour(swipe.direction == .left ? 1 : -1) { strip.pick(tab) } | |
| 788 | } | |
| 789 | ||
| 790 | override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { | |
| 791 | Prototype.sectionStrip && strip.notebook != nil ? strip : nil | |
| 792 | } | |
| 793 | ||
| 794 | override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { | |
| 795 | Prototype.sectionStrip && strip.notebook != nil ? 50 : 0 | |
| 743 | 796 | } |
| 744 | 797 | |
| 745 | 798 | override func viewWillAppear(_ animated: Bool) { |
| ... | ... | @@ -751,6 +804,10 @@ final class PagesViewController: UITableViewController { |
| 751 | 804 | section = nil |
| 752 | 805 | items = [] |
| 753 | 806 | title = tab.name |
| 807 | if Prototype.sectionStrip, let notebook = strip.notebook { | |
| 808 | title = notebook.name | |
| 809 | strip.select(tab.path) | |
| 810 | } | |
| 754 | 811 | tableView.reloadData() |
| 755 | 812 | var loading = UIContentUnavailableConfiguration.loading() |
| 756 | 813 | loading.text = "Opening…" |
| ... | ... | @@ -768,7 +825,7 @@ final class PagesViewController: UITableViewController { |
| 768 | 825 | navigationController?.setToolbarHidden(true, animated: false) |
| 769 | 826 | } |
| 770 | 827 | |
| 771 | func failed(_ tab: Tab, _ problem: String?) { | |
| 828 | func failed(_ problem: String?) { | |
| 772 | 829 | var empty = UIContentUnavailableConfiguration.empty() |
| 773 | 830 | empty.text = "Can’t Open Section" |
| 774 | 831 | empty.secondaryText = problem |
| ... | ... | @@ -778,6 +835,10 @@ final class PagesViewController: UITableViewController { |
| 778 | 835 | func load(_ section: Section) { |
| 779 | 836 | self.section = section |
| 780 | 837 | title = section.tab.name |
| 838 | if Prototype.sectionStrip { | |
| 839 | title = section.notebook.name | |
| 840 | strip.show(section.notebook, selected: section.tab.path) | |
| 841 | } | |
| 781 | 842 | navigationController?.setToolbarHidden(false, animated: false) |
| 782 | 843 | reload() |
| 783 | 844 | } |
| ... | ... | @@ -816,7 +877,7 @@ final class PagesViewController: UITableViewController { |
| 816 | 877 | } |
| 817 | 878 | |
| 818 | 879 | private func delete(_ item: Item) { |
| 819 | section?.delete(item.id) { _ in } | |
| 880 | section?.delete(item.id) | |
| 820 | 881 | } |
| 821 | 882 | |
| 822 | 883 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } |
| ... | ... | @@ -859,6 +920,8 @@ final class PagesViewController: UITableViewController { |
| 859 | 920 | override func tableView( |
| 860 | 921 | _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath |
| 861 | 922 | ) -> UISwipeActionsConfiguration? { |
| 923 | // A swipe across the list changes section instead; the context menu deletes. | |
| 924 | if Prototype.sectionStrip { return nil } | |
| 862 | 925 | let item = items[indexPath.row] |
| 863 | 926 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in |
| 864 | 927 | self?.delete(item) |
apps/ios/Snowbound/Notebooks.swift+66-44| ... | ... | @@ -30,7 +30,11 @@ enum Author { |
| 30 | 30 | |
| 31 | 31 | static var name: String? { |
| 32 | 32 | get { UserDefaults.standard.string(forKey: key) } |
| 33 | set { UserDefaults.standard.set(newValue, forKey: key) } | |
| 33 | set { | |
| 34 | UserDefaults.standard.set(newValue, forKey: key) | |
| 35 | // Open sections name it in their edits from now on. | |
| 36 | for section in Section.all { sb_section_set_author(section.handle, newValue ?? "") } | |
| 37 | } | |
| 34 | 38 | } |
| 35 | 39 | |
| 36 | 40 | /// Asks for the user name OneNote shows beside changes, then runs `then`. |
| ... | ... | @@ -126,13 +130,22 @@ struct Tab: Decodable, Equatable { |
| 126 | 130 | let group: String |
| 127 | 131 | let color: [UInt8] |
| 128 | 132 | let readable: Bool |
| 133 | /// Password protected and not unlocked: `Notebook.unlock` opens it. | |
| 134 | let locked: Bool | |
| 129 | 135 | /// Not on this device yet; `Notebook` asks iCloud Drive for it. |
| 130 | 136 | let downloading: Bool |
| 131 | 137 | /// Why the file could not be read, where retrying or repair may help. |
| 132 | 138 | let problem: String? |
| 133 | 139 | |
| 134 | var uiColor: UIColor { | |
| 135 | UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) | |
| 140 | var uiColor: UIColor { UIColor(rgb: color) } | |
| 141 | } | |
| 142 | ||
| 143 | extension UIViewController { | |
| 144 | /// Tells the reader `title` and `message` in an alert they dismiss with OK. | |
| 145 | func alert(_ title: String, _ message: String) { | |
| 146 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) | |
| 147 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 148 | present(alert, animated: true) | |
| 136 | 149 | } |
| 137 | 150 | } |
| 138 | 151 | |
| ... | ... | @@ -205,13 +218,8 @@ final class Notebook { |
| 205 | 218 | } |
| 206 | 219 | let accessed = url.startAccessingSecurityScopedResource() ? url : nil |
| 207 | 220 | let local = onThisDevice(url) |
| 208 | var library: OpaquePointer? | |
| 209 | // A coordinated read brings a cloud folder's files down before they are read. | |
| 210 | var coordination: NSError? | |
| 211 | NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in | |
| 212 | library = sb_library_open(url.path, cacheDirectory.path, local, &error) | |
| 213 | } | |
| 214 | return (library, accessed, take(error) ?? coordination?.localizedDescription, local ? url : nil) | |
| 221 | let (library, problem) = coordinatedOpen(url, local: local) | |
| 222 | return (library, accessed, problem, local ? url : nil) | |
| 215 | 223 | case .documents(let path): |
| 216 | 224 | let url = documentsDirectory.appendingPathComponent(path) |
| 217 | 225 | let library = sb_library_open(url.path, cacheDirectory.path, true, &error) |
| ... | ... | @@ -220,12 +228,8 @@ final class Notebook { |
| 220 | 228 | guard let url = ICloud.documents?.appendingPathComponent(path) else { |
| 221 | 229 | return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil) |
| 222 | 230 | } |
| 223 | var library: OpaquePointer? | |
| 224 | var coordination: NSError? | |
| 225 | NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in | |
| 226 | library = sb_library_open(url.path, cacheDirectory.path, false, &error) | |
| 227 | } | |
| 228 | return (library, nil, take(error) ?? coordination?.localizedDescription, url) | |
| 231 | let (library, problem) = coordinatedOpen(url, local: false) | |
| 232 | return (library, nil, problem, url) | |
| 229 | 233 | case .path(let path): |
| 230 | 234 | let url = URL(fileURLWithPath: path) |
| 231 | 235 | let local = onThisDevice(url) |
| ... | ... | @@ -262,6 +266,21 @@ final class Notebook { |
| 262 | 266 | } |
| 263 | 267 | } |
| 264 | 268 | |
| 269 | /// Unlocks the protected section at `path` with `password` off the main thread, then lists | |
| 270 | /// the sections again; `done` learns whether the password matched. | |
| 271 | func unlock(_ path: String, password: String, done: @escaping (Bool) -> Void) { | |
| 272 | guard let handle else { return done(false) } | |
| 273 | let pointer = Int(bitPattern: handle) | |
| 274 | background({ sb_library_unlock(OpaquePointer(bitPattern: pointer), path, password) }) { [self] unlocked in | |
| 275 | reload { done(unlocked) } | |
| 276 | } | |
| 277 | } | |
| 278 | ||
| 279 | /// Locks every protected section, as OneNote's Lock All does. | |
| 280 | func lockAll() { | |
| 281 | if let handle { sb_library_lock_all(handle) } | |
| 282 | } | |
| 283 | ||
| 265 | 284 | /// Lists the sections again, off the main thread. |
| 266 | 285 | func reload(done: @escaping () -> Void) { |
| 267 | 286 | guard let handle else { |
| ... | ... | @@ -323,6 +342,18 @@ final class Notebook { |
| 323 | 342 | } |
| 324 | 343 | } |
| 325 | 344 | |
| 345 | /// Opens the library at `url` within a coordinated read, which brings a cloud folder's files | |
| 346 | /// down before they are read; nil with why where it cannot. | |
| 347 | private func coordinatedOpen(_ url: URL, local: Bool) -> (OpaquePointer?, String?) { | |
| 348 | var library: OpaquePointer? | |
| 349 | var error: UnsafeMutablePointer<CChar>? | |
| 350 | var coordination: NSError? | |
| 351 | NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in | |
| 352 | library = sb_library_open(url.path, cacheDirectory.path, local, &error) | |
| 353 | } | |
| 354 | return (library, take(error) ?? coordination?.localizedDescription) | |
| 355 | } | |
| 356 | ||
| 326 | 357 | /// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere |
| 327 | 358 | /// too, as iCloud Drive does. |
| 328 | 359 | private func onThisDevice(_ url: URL) -> Bool { |
| ... | ... | @@ -414,7 +445,7 @@ enum Notebooks { |
| 414 | 445 | set { UserDefaults.standard.set(!newValue, forKey: hidden) } |
| 415 | 446 | } |
| 416 | 447 | |
| 417 | /// The kept notebooks and those in Documents, where the bundled sample goes on first launch. | |
| 448 | /// The kept notebooks and those in Documents. | |
| 418 | 449 | static func load() { |
| 419 | 450 | if let scripted { |
| 420 | 451 | elsewhere = [ |
| ... | ... | @@ -435,12 +466,6 @@ enum Notebooks { |
| 435 | 466 | return Notebook(id: entry.id, name: entry.name, source: entry.source) |
| 436 | 467 | } |
| 437 | 468 | } else { |
| 438 | let sample = documentsDirectory.appendingPathComponent("Sample") | |
| 439 | if !FileManager.default.fileExists(atPath: sample.path), | |
| 440 | let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil) | |
| 441 | { | |
| 442 | try? FileManager.default.copyItem(at: bundled, to: sample) | |
| 443 | } | |
| 444 | 469 | // The notebook the first version remembered from Files. |
| 445 | 470 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { |
| 446 | 471 | var stale = false |
| ... | ... | @@ -456,34 +481,31 @@ enum Notebooks { |
| 456 | 481 | /// Lists Documents again, keeping the notebooks still there; returns those new to the list. |
| 457 | 482 | @discardableResult |
| 458 | 483 | static func scan() -> [Notebook] { |
| 459 | guard showsOnDevice else { | |
| 460 | onDevice = [] | |
| 461 | return [] | |
| 462 | } | |
| 463 | var added: [Notebook] = [] | |
| 464 | onDevice = notebooks(in: documentsDirectory).map { name in | |
| 465 | if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept } | |
| 466 | let notebook = Notebook( | |
| 467 | id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name)) | |
| 468 | added.append(notebook) | |
| 469 | return notebook | |
| 470 | } | |
| 471 | return added | |
| 484 | let folder = showsOnDevice ? documentsDirectory : nil | |
| 485 | return rescan(folder, "Documents/", &onDevice) { .documents(path: $0) } | |
| 472 | 486 | } |
| 473 | 487 | |
| 474 | 488 | /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while |
| 475 | 489 | /// iCloud Drive is off. |
| 476 | 490 | @discardableResult |
| 477 | 491 | static func scanICloud() -> [Notebook] { |
| 478 | guard let folder = ICloud.documents, scripted == nil else { | |
| 479 | inCloud = [] | |
| 492 | rescan(scripted == nil ? ICloud.documents : nil, "iCloud/", &inCloud) { .icloud(path: $0) } | |
| 493 | } | |
| 494 | ||
| 495 | /// Lists `folder`'s notebooks into `list`, keeping those still there and naming new ones by | |
| 496 | /// `prefix` and their `source`; returns the new ones. | |
| 497 | private static func rescan( | |
| 498 | _ folder: URL?, _ prefix: String, _ list: inout [Notebook], source: (String) -> Source | |
| 499 | ) -> [Notebook] { | |
| 500 | guard let folder else { | |
| 501 | list = [] | |
| 480 | 502 | return [] |
| 481 | 503 | } |
| 482 | 504 | var added: [Notebook] = [] |
| 483 | inCloud = notebooks(in: folder).map { name in | |
| 484 | if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept } | |
| 505 | list = notebooks(in: folder).map { [list] name in | |
| 506 | if let kept = list.first(where: { $0.id == prefix + name }) { return kept } | |
| 485 | 507 | let notebook = Notebook( |
| 486 | id: "iCloud/" + name, name: (name as NSString).deletingPathExtension, source: .icloud(path: name)) | |
| 508 | id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) | |
| 487 | 509 | added.append(notebook) |
| 488 | 510 | return notebook |
| 489 | 511 | } |
| ... | ... | @@ -650,13 +672,13 @@ final class Section { |
| 650 | 672 | return id |
| 651 | 673 | } |
| 652 | 674 | |
| 653 | func delete(_ id: String, done: @escaping (Bool) -> Void) { | |
| 675 | func delete(_ id: String, done: @escaping () -> Void = {}) { | |
| 654 | 676 | let (date, time) = titleDate() |
| 655 | 677 | let pointer = Int(bitPattern: handle) |
| 656 | background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in | |
| 678 | background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in | |
| 657 | 679 | reloadRows() |
| 658 | 680 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) |
| 659 | done(deleted) | |
| 681 | done() | |
| 660 | 682 | } |
| 661 | 683 | } |
| 662 | 684 |
apps/ios/Snowbound/PageViewController.swift+32-16| ... | ... | @@ -21,6 +21,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 21 | 21 | private let titleFocus: Bool |
| 22 | 22 | private lazy var canvas = CanvasView(section: section, page: page) |
| 23 | 23 | private let bar = ConflictBar() |
| 24 | /// Reading View, beside the page menu where the page has one. | |
| 25 | private var reader: UIBarButtonItem? | |
| 24 | 26 | /// Opens another page or conflict page of the section. |
| 25 | 27 | var onOpen: ((String) -> Void)? |
| 26 | 28 | private lazy var done = UIBarButtonItem( |
| ... | ... | @@ -96,6 +98,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 96 | 98 | if revealed || titleFocus && canvas.focusTitle() { |
| 97 | 99 | _ = canvas.becomeFirstResponder() |
| 98 | 100 | } |
| 101 | if Prototype.reading, canvas.reading?.offered == true { | |
| 102 | reader = UIBarButtonItem( | |
| 103 | title: "Reading View", image: UIImage(systemName: "text.justify.left"), | |
| 104 | primaryAction: UIAction { [weak self] _ in self?.toggleReading() }) | |
| 105 | editingChanged() | |
| 106 | } | |
| 99 | 107 | } |
| 100 | 108 | |
| 101 | 109 | private func showTitle(_ title: String) { |
| ... | ... | @@ -121,8 +129,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 121 | 129 | undo.isEnabled = canvas.canUndo(redo: false) |
| 122 | 130 | redo.isEnabled = canvas.canUndo(redo: true) |
| 123 | 131 | // Drawing keeps undo at hand, as typing does. |
| 132 | reader?.isSelected = canvas.readingShown | |
| 124 | 133 | guard canvas.isFirstResponder || canvas.pickerShown else { |
| 125 | if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more, draw] } | |
| 134 | let resting = [more, draw] + (reader.map { [$0] } ?? []) | |
| 135 | if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting } | |
| 126 | 136 | return |
| 127 | 137 | } |
| 128 | 138 | let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] |
| ... | ... | @@ -164,7 +174,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 164 | 174 | title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable |
| 165 | 175 | ) { [weak self] _ in self?.canvas.insertSpace() }, |
| 166 | 176 | ]) |
| 167 | return [ | |
| 177 | return (Prototype.reading ? [readingAction()] : []) + [ | |
| 168 | 178 | insert, |
| 169 | 179 | UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { |
| 170 | 180 | [weak self] _ in self?.showPaper() |
| ... | ... | @@ -189,12 +199,27 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 189 | 199 | UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) { |
| 190 | 200 | [weak self] _ in |
| 191 | 201 | guard let self else { return } |
| 192 | section.delete(page) { _ in } | |
| 202 | section.delete(page) | |
| 193 | 203 | }, |
| 194 | 204 | ]), |
| 195 | 205 | ] |
| 196 | 206 | } |
| 197 | 207 | |
| 208 | /// Reading View: the page reflowed into the screen's width, read-only, where it reflows. | |
| 209 | private func readingAction() -> UIAction { | |
| 210 | let reading = canvas.reading | |
| 211 | return UIAction( | |
| 212 | title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) }, | |
| 213 | image: UIImage(systemName: "text.justify.left"), | |
| 214 | attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled, | |
| 215 | state: canvas.readingShown ? .on : .off | |
| 216 | ) { [weak self] _ in self?.toggleReading() } | |
| 217 | } | |
| 218 | ||
| 219 | private func toggleReading() { | |
| 220 | canvas.showReading(!canvas.readingShown) | |
| 221 | } | |
| 222 | ||
| 198 | 223 | /// The page's theme, its section's and its notebook's, each pickable; the page wears a |
| 199 | 224 | /// new one when it opens again. |
| 200 | 225 | private func themeMenu() -> UIMenu { |
| ... | ... | @@ -228,12 +253,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 228 | 253 | let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) |
| 229 | 254 | let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" |
| 230 | 255 | guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { |
| 231 | let alert = UIAlertController( | |
| 232 | title: export ? "Couldn't Export the PDF" : "Couldn't Print", | |
| 233 | message: "The page could not be laid out on paper.", preferredStyle: .alert) | |
| 234 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 235 | present(alert, animated: true) | |
| 236 | return | |
| 256 | return alert( | |
| 257 | export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.") | |
| 237 | 258 | } |
| 238 | 259 | if export { |
| 239 | 260 | let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") |
| ... | ... | @@ -274,12 +295,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 274 | 295 | // Nothing typed is lost unannounced: the page's text goes on the clipboard. |
| 275 | 296 | UIPasteboard.general.string = canvas.pageText |
| 276 | 297 | canvas.reload(discard: true) |
| 277 | let alert = UIAlertController( | |
| 278 | title: "Change Not Saved", | |
| 279 | message: "The page shows what was last saved. Your text is on the clipboard to paste back.", | |
| 280 | preferredStyle: .alert) | |
| 281 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 282 | present(alert, animated: true) | |
| 298 | alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.") | |
| 283 | 299 | } else if flags & Section.remote != 0 { |
| 284 | 300 | canvas.reload(discard: false) |
| 285 | 301 | } |
| ... | ... | @@ -332,7 +348,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 332 | 348 | private func conflictAction() { |
| 333 | 349 | guard let (row, version) = section.row(of: page) else { return } |
| 334 | 350 | if version != nil { |
| 335 | section.delete(page) { [weak self] _ in self?.onOpen?(row.id) } | |
| 351 | section.delete(page) { [weak self] in self?.onOpen?(row.id) } | |
| 336 | 352 | } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { |
| 337 | 353 | onOpen?(newest.id) |
| 338 | 354 | } |
apps/ios/Snowbound/Prototypes.swift created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | import UIKit | |
| 2 | ||
| 3 | /// Design prototypes, off unless a launch argument turns one on, as | |
| 4 | /// `-SnowboundReading YES`, `-SnowboundWelcome YES` or `-SnowboundSectionStrip YES` (Xcode's | |
| 5 | /// scheme, `simctl launch`, or `devicectl … --arguments`). | |
| 6 | enum Prototype { | |
| 7 | /// Page menu: Reading View, the page reflowed into the screen's width, read-only. | |
| 8 | static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") } | |
| 9 | } | |
| 10 | ||
| 11 | extension Prototype { | |
| 12 | /// What the page menu says under Reading View for a verdict from `sb_view_reading`. | |
| 13 | static func readingNote(_ verdict: String) -> String? { | |
| 14 | let notes = [ | |
| 15 | ("Fits", "Page already fits"), | |
| 16 | ("RightToLeft", "Right-to-left pages not yet"), | |
| 17 | ("Drawn", "Mostly drawings"), | |
| 18 | ("Arranged", "Laid out as a canvas"), | |
| 19 | ("Asides", "Side notes follow their lines"), | |
| 20 | ("SideBySide", "Columns read left to right"), | |
| 21 | ("Group", "Overlapping parts stay together"), | |
| 22 | ("Wide", "Some parts stay wide"), | |
| 23 | ] | |
| 24 | let found = notes.filter { verdict.contains($0.0) }.map(\.1) | |
| 25 | return found.isEmpty ? nil : found.joined(separator: " · ") | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | extension Prototype { | |
| 30 | /// First launch: a welcome where the list would be empty, the first notebook found opened | |
| 31 | /// at its first page, and the name asked at the first edit rather than the first open. | |
| 32 | static var welcome: Bool { UserDefaults.standard.bool(forKey: "SnowboundWelcome") } | |
| 33 | ||
| 34 | /// The notebook list's welcome while there are no notebooks, nil once there are or while | |
| 35 | /// iCloud Drive is still being looked up. | |
| 36 | static func welcome( | |
| 37 | new: @escaping () -> Void, openFolder: @escaping () -> Void, connect: @escaping () -> Void | |
| 38 | ) -> UIContentUnavailableConfiguration? { | |
| 39 | guard Notebooks.all.isEmpty, !ICloud.signedIn || ICloud.documents != nil else { return nil } | |
| 40 | var welcome = UIContentUnavailableConfiguration.empty() | |
| 41 | welcome.image = UIImage(systemName: "book.closed") | |
| 42 | welcome.text = "No Notebooks Yet" | |
| 43 | welcome.secondaryText = | |
| 44 | ICloud.documents != nil | |
| 45 | ? "New notebooks go in iCloud Drive, where OneNote and your other devices can open them." | |
| 46 | : "New notebooks stay on this iPhone. Turn on iCloud Drive to use them on all your devices." | |
| 47 | var button = UIButton.Configuration.borderedProminent() | |
| 48 | button.title = "New Notebook" | |
| 49 | welcome.button = button | |
| 50 | welcome.buttonProperties.primaryAction = UIAction { _ in new() } | |
| 51 | var open = UIButton.Configuration.plain() | |
| 52 | open.title = "Open Existing Notebook" | |
| 53 | welcome.secondaryButton = open | |
| 54 | var elsewhere = [ | |
| 55 | UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { _ in openFolder() }, | |
| 56 | UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { _ in connect() }, | |
| 57 | ] | |
| 58 | if !ICloud.signedIn { | |
| 59 | elsewhere.append( | |
| 60 | UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in | |
| 61 | if let settings = URL(string: UIApplication.openSettingsURLString) { | |
| 62 | UIApplication.shared.open(settings) | |
| 63 | } | |
| 64 | }) | |
| 65 | } | |
| 66 | welcome.secondaryButtonProperties.menu = UIMenu(children: elsewhere) | |
| 67 | return welcome | |
| 68 | } | |
| 69 | ||
| 70 | /// Asks for the name edits are stored under, then runs `then`. | |
| 71 | static func askName(from controller: UIViewController, then: @escaping () -> Void) { | |
| 72 | let alert = UIAlertController( | |
| 73 | title: "Your Name", message: "OneNote shows it beside the pages and changes you make.", | |
| 74 | preferredStyle: .alert) | |
| 75 | let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in | |
| 76 | Author.name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) | |
| 77 | then() | |
| 78 | } | |
| 79 | save.isEnabled = false | |
| 80 | alert.addTextField { field in | |
| 81 | field.placeholder = "Name" | |
| 82 | field.textContentType = .name | |
| 83 | field.autocapitalizationType = .words | |
| 84 | NotificationCenter.default.addObserver( | |
| 85 | forName: UITextField.textDidChangeNotification, object: field, queue: .main | |
| 86 | ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty } | |
| 87 | } | |
| 88 | alert.addAction(UIAlertAction(title: "Not Now", style: .cancel)) | |
| 89 | alert.addAction(save) | |
| 90 | alert.preferredAction = save | |
| 91 | (controller.presentedViewController ?? controller).present(alert, animated: true) | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | extension Prototype { | |
| 96 | /// Pages list: the notebook's sections as OneNote's coloured tabs over the pages; a tab or | |
| 97 | /// a swipe across the list changes section. | |
| 98 | static var sectionStrip: Bool { UserDefaults.standard.bool(forKey: "SnowboundSectionStrip") } | |
| 99 | } | |
| 100 | ||
| 101 | /// A notebook's sections as a row of coloured tabs, the open one raised; a section group is a | |
| 102 | /// tab whose menu holds its sections. | |
| 103 | final class SectionStrip: UIView { | |
| 104 | private let scroll = UIScrollView() | |
| 105 | private let row = UIStackView() | |
| 106 | private let rule = UIView() | |
| 107 | private(set) var notebook: Notebook? | |
| 108 | private var selected = "" | |
| 109 | private let onPick: (Tab, Notebook) -> Void | |
| 110 | ||
| 111 | init(onPick: @escaping (Tab, Notebook) -> Void) { | |
| 112 | self.onPick = onPick | |
| 113 | super.init(frame: CGRect(x: 0, y: 0, width: 320, height: 50)) | |
| 114 | backgroundColor = .systemBackground | |
| 115 | scroll.showsHorizontalScrollIndicator = false | |
| 116 | row.axis = .horizontal | |
| 117 | row.spacing = 2 | |
| 118 | row.alignment = .bottom | |
| 119 | for view in [scroll, rule] as [UIView] { | |
| 120 | view.translatesAutoresizingMaskIntoConstraints = false | |
| 121 | addSubview(view) | |
| 122 | } | |
| 123 | row.translatesAutoresizingMaskIntoConstraints = false | |
| 124 | scroll.addSubview(row) | |
| 125 | NSLayoutConstraint.activate([ | |
| 126 | scroll.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| 127 | scroll.trailingAnchor.constraint(equalTo: trailingAnchor), | |
| 128 | scroll.topAnchor.constraint(equalTo: topAnchor, constant: 6), | |
| 129 | scroll.bottomAnchor.constraint(equalTo: rule.topAnchor), | |
| 130 | rule.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| 131 | rule.trailingAnchor.constraint(equalTo: trailingAnchor), | |
| 132 | rule.bottomAnchor.constraint(equalTo: bottomAnchor), | |
| 133 | rule.heightAnchor.constraint(equalToConstant: 3), | |
| 134 | row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12), | |
| 135 | row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12), | |
| 136 | row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), | |
| 137 | row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), | |
| 138 | row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), | |
| 139 | ]) | |
| 140 | } | |
| 141 | ||
| 142 | required init?(coder: NSCoder) { fatalError() } | |
| 143 | ||
| 144 | /// The readable sections in the notebook's order, groups' sections where the group is. | |
| 145 | private var readable: [Tab] { notebook?.tabs.filter(\.readable) ?? [] } | |
| 146 | ||
| 147 | func show(_ notebook: Notebook, selected path: String) { | |
| 148 | let first = self.notebook == nil | |
| 149 | self.notebook = notebook | |
| 150 | select(path) | |
| 151 | #if DEBUG | |
| 152 | // `SNOWBOUND_STRIP_TOUR=N` steps N sections on, one each 1.5 s, for recordings. | |
| 153 | if first, let steps = ProcessInfo.processInfo.environment["SNOWBOUND_STRIP_TOUR"].flatMap(Int.init) { | |
| 154 | for step in 1...max(1, steps) { | |
| 155 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5 * Double(step)) { [weak self] in | |
| 156 | if let tab = self?.neighbour(1) { self?.pick(tab) } | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | #endif | |
| 161 | } | |
| 162 | ||
| 163 | func select(_ path: String) { | |
| 164 | selected = path | |
| 165 | guard let notebook else { return } | |
| 166 | for view in row.arrangedSubviews { view.removeFromSuperview() } | |
| 167 | var groups: Set<String> = [] | |
| 168 | for tab in notebook.tabs where tab.readable { | |
| 169 | let group = tab.group.split(separator: "/").first.map(String.init) | |
| 170 | if let group { | |
| 171 | guard groups.insert(group).inserted else { continue } | |
| 172 | let inside = notebook.tabs.filter { $0.readable && $0.group.hasPrefix(group) } | |
| 173 | let current = inside.first { $0.path == path } | |
| 174 | row.addArrangedSubview( | |
| 175 | button(current?.name ?? group, color: current?.uiColor ?? .systemGray, raised: current != nil, | |
| 176 | folder: true, menu: UIMenu(title: group, children: inside.map { tab in | |
| 177 | UIAction(title: tab.name, image: swatch(tab.uiColor), | |
| 178 | state: tab.path == path ? .on : .off) { [weak self] _ in self?.pick(tab) } | |
| 179 | }))) | |
| 180 | } else { | |
| 181 | let tab = tab | |
| 182 | let button = button(tab.name, color: tab.uiColor, raised: tab.path == path, folder: false, menu: nil) | |
| 183 | button.addAction(UIAction { [weak self] _ in self?.pick(tab) }, for: .primaryActionTriggered) | |
| 184 | row.addArrangedSubview(button) | |
| 185 | } | |
| 186 | } | |
| 187 | rule.backgroundColor = notebook.tabs.first { $0.path == path }?.uiColor ?? .separator | |
| 188 | layoutIfNeeded() | |
| 189 | if let raised = row.arrangedSubviews.first(where: { $0.tag == 1 }) { | |
| 190 | scroll.scrollRectToVisible(raised.frame.insetBy(dx: -40, dy: 0), animated: false) | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | /// The readable section `step` places after the open one, if any. | |
| 195 | func neighbour(_ step: Int) -> Tab? { | |
| 196 | let tabs = readable | |
| 197 | guard let at = tabs.firstIndex(where: { $0.path == selected }), tabs.indices.contains(at + step) else { | |
| 198 | return nil | |
| 199 | } | |
| 200 | return tabs[at + step] | |
| 201 | } | |
| 202 | ||
| 203 | func pick(_ tab: Tab) { | |
| 204 | guard let notebook else { return } | |
| 205 | select(tab.path) | |
| 206 | onPick(tab, notebook) | |
| 207 | } | |
| 208 | ||
| 209 | private func button(_ title: String, color: UIColor, raised: Bool, folder: Bool, menu: UIMenu?) -> UIButton { | |
| 210 | var configuration = UIButton.Configuration.filled() | |
| 211 | configuration.title = title | |
| 212 | configuration.image = folder ? UIImage(systemName: "folder") : nil | |
| 213 | configuration.imagePadding = 4 | |
| 214 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1) | |
| 215 | configuration.baseBackgroundColor = raised ? color : color.withAlphaComponent(0.28) | |
| 216 | configuration.baseForegroundColor = raised ? .white : .label | |
| 217 | configuration.background.cornerRadius = 9 | |
| 218 | configuration.contentInsets = NSDirectionalEdgeInsets( | |
| 219 | top: raised ? 11 : 8, leading: 14, bottom: raised ? 9 : 7, trailing: 14) | |
| 220 | configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { | |
| 221 | var attributes = $0 | |
| 222 | attributes.font = .preferredFont(forTextStyle: .subheadline).withTraits(raised ? .traitBold : []) | |
| 223 | return attributes | |
| 224 | } | |
| 225 | let button = UIButton(configuration: configuration) | |
| 226 | // Tabs: only the top corners round, the open one meeting the rule beneath. | |
| 227 | button.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] | |
| 228 | button.layer.cornerRadius = 9 | |
| 229 | button.clipsToBounds = true | |
| 230 | button.tag = raised ? 1 : 0 | |
| 231 | if let menu { | |
| 232 | button.menu = menu | |
| 233 | button.showsMenuAsPrimaryAction = true | |
| 234 | } | |
| 235 | return button | |
| 236 | } | |
| 237 | ||
| 238 | private func swatch(_ color: UIColor) -> UIImage? { | |
| 239 | UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal) | |
| 240 | } | |
| 241 | } |
apps/ios/Snowbound/Script.swift+5-3| ... | ... | @@ -14,7 +14,7 @@ extension CanvasView { |
| 14 | 14 | /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the |
| 15 | 15 | /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes |
| 16 | 16 | /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done` |
| 17 | /// ends editing, `tree` saves the view hierarchy to | |
| 17 | /// ends editing, `reading` turns the reading view on or off, `tree` saves the view hierarchy to | |
| 18 | 18 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no |
| 19 | 19 | /// screenshot command. |
| 20 | 20 | func runScript() { |
| ... | ... | @@ -73,8 +73,9 @@ extension CanvasView { |
| 73 | 73 | from: position(from: beginningOfDocument, offset: values[0])!, |
| 74 | 74 | to: position(from: beginningOfDocument, offset: values[1])!) |
| 75 | 75 | case "scroll": |
| 76 | let values = argument.split(separator: ",").compactMap { Double($0) } | |
| 77 | setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) | |
| 76 | // `scroll:,Y` keeps the horizontal offset. | |
| 77 | let values = argument.split(separator: ",", omittingEmptySubsequences: false).map { Double($0) } | |
| 78 | setContentOffset(CGPoint(x: values[0] ?? contentOffset.x, y: values[1] ?? contentOffset.y), animated: true) | |
| 78 | 79 | case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) |
| 79 | 80 | case "doubletap": |
| 80 | 81 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| ... | ... | @@ -90,6 +91,7 @@ extension CanvasView { |
| 90 | 91 | menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) |
| 91 | 92 | case "done": _ = resignFirstResponder() |
| 92 | 93 | case "selectall": selectAll(nil) |
| 94 | case "reading": showReading(!readingShown) | |
| 93 | 95 | case "format": apply(UInt8(argument) ?? 0) |
| 94 | 96 | case "find": _ = find(argument) |
| 95 | 97 | case "title": _ = focusTitle() |
apps/ios/Snowbound/Server.swift-6| ... | ... | @@ -110,12 +110,6 @@ final class ServerViewController: UITableViewController, UITextFieldDelegate { |
| 110 | 110 | } |
| 111 | 111 | } |
| 112 | 112 | |
| 113 | private func alert(_ title: String, _ message: String) { | |
| 114 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) | |
| 115 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 116 | present(alert, animated: true) | |
| 117 | } | |
| 118 | ||
| 119 | 113 | override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } |
| 120 | 114 | |
| 121 | 115 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } |
arc/canvas.md+5| ... | ... | @@ -84,6 +84,11 @@ same line heights, same places. That took measuring, not guessing. |
| 84 | 84 | Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags |
| 85 | 85 | sit in a column to the left of the text with OneNote's spacing, and a tag's |
| 86 | 86 | colour paints the whole paragraph as it does there. |
| 87 | - **A right-to-left page is the same frame, seen from its right.** OneNote 2010 | |
| 88 | keeps its positions left to right like any page's, from a margin origin some | |
| 89 | 10,800 pt out, and opens it scrolled to the right end of its content, keeping | |
| 90 | that edge as the window resizes. The canvas does the same and stores clicks, | |
| 91 | strokes and drags as on any page (`corpus/rtl-page`). | |
| 87 | 92 | |
| 88 | 93 | These rules were established against OneNote's own output: its XML export |
| 89 | 94 | gives outline sizes, its PDF export gives exact line breaks, and screenshots |
arc/file-format.md+13-5| ... | ... | @@ -182,11 +182,19 @@ tools), while the writer stays strict. |
| 182 | 182 | |
| 183 | 183 | ## Protected sections |
| 184 | 184 | |
| 185 | Password-protected sections keep their encrypted structure. With the | |
| 186 | `protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied | |
| 187 | password. Edits are sealed back under the section's key, with a fresh IV for | |
| 188 | each object. Plaintext never reaches the replica's cache. A protected section is | |
| 189 | edited directly in its file and never queued. | |
| 185 | A password-protected section keeps its structure in the clear and encrypts every | |
| 186 | object and payload under one AES-128 key, which Office's Agile password encryption | |
| 187 | wraps (SHA-1, 100,000 rounds). `protected::Key` opens it with the password, and | |
| 188 | `Section::unlock` keeps the section open under it: objects decode into the arena as it | |
| 189 | opens, and each seal encrypts what it appends with a fresh IV and names the key in every | |
| 190 | revision. Edits are ops like any other's; only the bytes differ. | |
| 191 | ||
| 192 | Setting, changing or removing a password is one of the few whole-image writes, as it is | |
| 193 | in OneNote 2010, which writes the section anew under a new file identity, new object | |
| 194 | space identities and new payload identities, and leaves its TOC to follow. Snowbound does | |
| 195 | the same (`protected::rekey`): keeping any identity would let a cache, OneNote's or a | |
| 196 | replica, mistake the old file's revisions or payloads for the new file's. The evidence, | |
| 197 | with the crypto, is in `corpus/protected-sections`. | |
| 190 | 198 | |
| 191 | 199 | ## Looking inside |
| 192 | 200 |
arc/platforms.md+20-8| ... | ... | @@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system. |
| 10 | 10 | shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) |
| 11 | 11 | glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) |
| 12 | 12 | page canvas ─────────────────────────────────────────────────────────────────────► |
| 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal) | |
| 13 | paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12; D3D11 on 7) draw (Metal) | |
| 14 | 14 | data notebook::session + embedded SMB client ────────────────────────────────────► |
| 15 | 15 | format onestore ───────────────────────────────────────────────────────────────────► |
| 16 | 16 | ``` |
| ... | ... | @@ -103,9 +103,15 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 103 | 103 | imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 |
| 104 | 104 | and later that dependencies still name are answered by |
| 105 | 105 | `platform/windows/rt/shims.c`; everything newer is looked up at run time. |
| 106 | - `draw` builds both backends on Windows and the surface picks one: | |
| 106 | - `draw` builds three backends on Windows and the surface picks one: | |
| 107 | 107 | Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise |
| 108 | OpenGL 2.1 on a WGL context, as on Windows 7 (`surface_windows.rs`). | |
| 108 | Direct3D 11, as on Windows 7, on WARP where no device reaches feature | |
| 109 | level 10_0, and OpenGL 2.1 on a WGL context only where neither starts | |
| 110 | (`surface_windows.rs`). Direct3D 11 presents through a blit-model DXGI | |
| 111 | swap chain, as 7 has no flip model, and through DirectComposition on 10, | |
| 112 | whose window has no redirection surface; OpenGL drivers such as Intel's | |
| 113 | place their frames below the caption the system would draw even where the | |
| 114 | row takes its place. | |
| 109 | 115 | - The toolbar's row is the title bar wherever the desktop composes windows. |
| 110 | 116 | On Windows 7 with Aero and on 11, the system's frame keeps its sides, |
| 111 | 117 | its caption gives way to the row, its material (Aero glass, Mica) lies |
| ... | ... | @@ -115,11 +121,17 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 115 | 121 | draws them as 11 does, lit where the system reports the pointer. On 8 |
| 116 | 122 | and 10, whose frames are opaque, the window has no system frame and the |
| 117 | 123 | row draws and runs caption buttons as 10 does, over acrylic on 10. Over |
| 118 | 7's glass the sidebar stays opaque, and fields and tool buttons take white | |
| 119 | faces, as Internet Explorer's do there. The theme and material follow the | |
| 120 | system's colour mode as it changes. With Windows 7's basic or classic | |
| 121 | theme the system draws the title bar and the row lies beneath it, as on | |
| 122 | KDE. | |
| 124 | 7's glass, which takes any colour and whatever lies behind the window, | |
| 125 | the toolbar keeps opaque faces under its icons: by default the strip is | |
| 126 | opaque, as Explorer's command bar is under its glass, stopping short of the | |
| 127 | caption buttons 7 draws beneath the window's pixels, and | |
| 128 | `SNOWBOUND_W7_CHROME` tries the alternatives, `pills` for a face per group | |
| 129 | of tools over the glass, `frost` for a strip the glass tints through, and | |
| 130 | `tint` and `tint-tiles` for one panel or a tile per group in the glass's | |
| 131 | colour at a lightness text keeps its contrast on, as Mica tints. | |
| 132 | The theme and material follow the system's colour mode as it changes. | |
| 133 | With Windows 7's basic or classic theme the system draws the title bar | |
| 134 | and the row lies beneath it, as on KDE. | |
| 123 | 135 | - A notebook on a share opens by its UNC path through Windows' own SMB |
| 124 | 136 | client, which takes OneNote's opens and locks natively: `onestore` opens a |
| 125 | 137 | section as OneNote does (a reader shares it with everyone, a writer denies |
arc/sync.md+9-1| ... | ... | @@ -55,7 +55,15 @@ reopens the same queue after a relaunch. It holds: |
| 55 | 55 | - **payloads**: picture and attachment bytes, stored once each, keyed by hash. |
| 56 | 56 | |
| 57 | 57 | The working state is never stored. It is the base with each sealed batch |
| 58 | replayed and the open batch applied, rebuilt on open. The database runs in WAL | |
| 58 | replayed and the open batch applied, rebuilt on open. | |
| 59 | ||
| 60 | A password-protected section's replica holds nothing in the clear. Its base and sealed | |
| 61 | transactions are the file's own ciphertext, and its edits and payloads are sealed with | |
| 62 | AES-256-GCM under a key derived from the section's, so the queue opens, offline or after a | |
| 63 | relaunch, only once the section is unlocked. A locked section is never opened in the | |
| 64 | background; its edits wait until it is unlocked again. A replica of a section another device | |
| 65 | then protects is deleted the next time the notebook is read, unless edits wait in it; those | |
| 66 | wait for the key, then come back as copies of the pages they changed. The database runs in WAL | |
| 59 | 67 | mode with full synchronous commits, and every setting is read back to check it |
| 60 | 68 | took. |
| 61 | 69 |
corpus/m6/native-page-direction-03/ORACLE.md+4-3| ... | ... | @@ -5,9 +5,10 @@ table columns to 96 and 144 points through OneNote COM. `before.xml`, `after.xml |
| 5 | 5 | and `read/page-000.xml` retain the native observations. The saved store uses |
| 6 | 6 | visual left-to-right column order [Right cell, Left cell], with widths [144, 96]. |
| 7 | 7 | COM exports cells in page direction [Left cell, Right cell], with widths [96, 144]. |
| 8 | The native PDF independently confirms the physical cell order. Default outline alignment anchors the right edge: | |
| 9 | store x + LayoutMaxWidth - PageMarginOriginX - 36 equals native x + native width. | |
| 10 | The native table outline measures wider than its stored suggested maximum width. | |
| 8 | The native PDF independently confirms the physical cell order. Native x is store x - | |
| 9 | PageMarginOriginX - 36; the native table outline measures wider than its stored suggested | |
| 10 | maximum width and keeps its right edge, store x + LayoutMaxWidth - PageMarginOriginX - 36 | |
| 11 | equalling native x + native width (`corpus/rtl-page` has narrower outlines keep their left). | |
| 11 | 12 | Vertical coordinates retain the 14.4-point canonical origin. |
| 12 | 13 | |
| 13 | 14 | The public fixture includes images, a file and two opaque ink objects. All |
corpus/notebook-package/README.md created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | # Notebook packages | |
| 2 | ||
| 3 | OneNote 2010's Save As offers a page and a section as a OneNote 2010 section (`.one`), 2007 | |
| 4 | section, PDF, XPS or single-file web page, and a notebook as a OneNote Package (`.onepkg`), PDF | |
| 5 | or XPS. Observed in a lab clone on 2026-10-01: | |
| 6 | ||
| 7 | - `native/Lab.onepkg` is OneNote's package of a notebook of two sections and a recycle bin: an | |
| 8 | LZX (2^18-byte window) cabinet of the TOC and sections at their paths, the bin left out though | |
| 9 | the TOC keeps its entry; each file written anew, with no `guidAncestor` or `crcName`. | |
| 10 | - A section or page saved as a section is a file of its own identity, outside any notebook, | |
| 11 | which OneNote then opens among Open Sections. | |
| 12 | - Unpack Notebook (opening a package) asks for the name (the package's), colour (the package's) | |
| 13 | and folder, writes each file with a new identity and version, places it under its folder's | |
| 14 | TOC, and lists it anew beside its stale entry. | |
| 15 | - Show Unread Changes, Mark as Read and what is unread live in OneNote's local cache, not in the | |
| 16 | notebook's files or the registry. | |
| 17 | ||
| 18 | `candidate/Packed.onepkg` and `candidate/Unpacked` are what | |
| 19 | `a_notebook_packs_and_unpacks_as_onenote_packs_one` in `crates/notebook/tests/package.rs` | |
| 20 | exports with `NOTEBOOK_PACKAGE_EXPORT`: Snowbound's MSZIP package of a notebook with a section | |
| 21 | group, and the notebook Snowbound unpacked from it. `onenote-unpacked` is OneNote 2010 unpacking | |
| 22 | Snowbound's package through its Unpack Notebook dialog; `cold` is a fresh OneNote 2010's read of | |
| 23 | Snowbound's unpacked notebook, every file left as written. `save-as/candidate` holds Save As's | |
| 24 | copy of a section and section of a page as `save_as_writes_copies_outside_the_notebook` exports | |
| 25 | them with `NOTEBOOK_SAVE_AS_EXPORT`; `save-as/cold` is OneNote 2010 opening their folder, which | |
| 26 | adopts them (new identities, placed) and reads each page. `tools/test_notebook_package.py` | |
| 27 | checks them all without a VM. |
corpus/notebook-package/candidate/Packed.onepkg created| Binary files /dev/null and b/corpus/notebook-package/candidate/Packed.onepkg differ |
corpus/notebook-package/candidate/Unpacked/Group/Inner.one created| Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Group/Inner.one differ |
corpus/notebook-package/candidate/Unpacked/Group/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Group/Open Notebook.onetoc2 differ |
corpus/notebook-package/candidate/Unpacked/New Section 1.one created| Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/New Section 1.one differ |
corpus/notebook-package/candidate/Unpacked/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Open Notebook.onetoc2 differ |
corpus/notebook-package/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-7F93E47C -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 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/notebook-package/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-7f93e47c", "hostname": "ONE-M6-7F93E47C"} |
corpus/notebook-package/cold/notebook/Group/Inner.one created| Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Group/Inner.one differ |
corpus/notebook-package/cold/notebook/Group/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Group/Open Notebook.onetoc2 differ |
corpus/notebook-package/cold/notebook/New Section 1.one created| Binary files /dev/null and b/corpus/notebook-package/cold/notebook/New Section 1.one differ |
corpus/notebook-package/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Open Notebook.onetoc2 differ |
corpus/notebook-package/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-7F93E47C", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/notebook-package/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="{AB56BDDF-D69D-4AEB-999F-142EBBDF5584}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T18:34:15.000Z" color="#FFD700"><one:Section name="New Section 1" ID="{89D5960B-F35F-0731-3540-AABB77AAA95A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T18:34:15.000Z" color="#8AA8E4"><one:Page ID="{F7A1CC38-E8EC-0005-3EC2-71DD870C8960}{1}{B0}" name="First" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:15.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="Group" ID="{BE96D1A4-8277-48FE-9B50-48CB7D4FD804}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-10-01T18:34:15.000Z"><one:Section name="Inner" ID="{D4DFCA41-2840-0A19-0EF7-A9B8CD68E056}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-10-01T18:34:15.000Z" color="#8AA8E4"><one:Page ID="{6D6DF4A9-68EB-095F-1C6F-42584EDCE929}{1}{B0}" name="Inside" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:15.000Z" pageLevel="1"/></one:Section></one:SectionGroup></one:Notebook> |
corpus/notebook-package/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": "Inside - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/notebook-package/cold/read/page-000.png created| Binary files /dev/null and b/corpus/notebook-package/cold/read/page-000.png differ |
corpus/notebook-package/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="{6D6DF4A9-68EB-095F-1C6F-42584EDCE929}{1}{B0}" name="Inside" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:15.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:18:04.000Z" objectID="{6D6DF4A9-68EB-095F-1C6F-42584EDCE929}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Inside]]></one:T></one:OE></one:Title></one:Page> |
corpus/notebook-package/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": "First - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/notebook-package/cold/read/page-001.png created| Binary files /dev/null and b/corpus/notebook-package/cold/read/page-001.png differ |
corpus/notebook-package/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="{F7A1CC38-E8EC-0005-3EC2-71DD870C8960}{1}{B0}" name="First" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:15.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:18:04.000Z" objectID="{F7A1CC38-E8EC-0005-3EC2-71DD870C8960}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[First]]></one:T></one:OE></one:Title></one:Page> |
corpus/notebook-package/cold/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/notebook-package/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-notebook-features/package-export/Unpacked", | |
| 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/notebook-package/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/notebook-package/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/notebook-package/cold/source.json created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Group/Inner.one", | |
| 4 | "bytes": 6032, | |
| 5 | "sha256": "a5c70b17a6885e7c8852c69520c6be71961546e0fedf6ad0ff380e3db130e1ce", | |
| 6 | "mtime_ns": 1790878684326770910 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Group/Open Notebook.onetoc2", | |
| 10 | "bytes": 4120, | |
| 11 | "sha256": "204a6f736bf18e2304bd15d458e41d2f411b7f25a3be71337c4ed45071d300da", | |
| 12 | "mtime_ns": 1790878684326669077 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "New Section 1.one", | |
| 16 | "bytes": 6024, | |
| 17 | "sha256": "842735103d262398916182756bcb46e3eafd602801cb37d695e4dd96afbd9f05", | |
| 18 | "mtime_ns": 1790878684326501410 | |
| 19 | }, | |
| 20 | { | |
| 21 | "path": "Open Notebook.onetoc2", | |
| 22 | "bytes": 6120, | |
| 23 | "sha256": "0f79ac377df5e59f49435239ad250b284a461da433eea502a8ec68f02e9d5479", | |
| 24 | "mtime_ns": 1790878684326399618 | |
| 25 | } | |
| 26 | ] |
corpus/notebook-package/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/notebook-package/native/Lab.onepkg created| Binary files /dev/null and b/corpus/notebook-package/native/Lab.onepkg differ |
corpus/notebook-package/onenote-unpacked/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebooks xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote"><one:Notebook name="Packed" nickname="Packed" ID="{3D9E38DC-8D13-408A-B3B0-96F1D74A5F50}{1}{B0}" path="C:\lab\Packed" lastModifiedTime="2026-10-01T18:34:32.000Z" color="#FFD700" isCurrentlyViewed="true"><one:Section name="New Section 1" ID="{9C7E1E7F-637E-0EAD-3653-B978BB08F10E}{1}{B0}" path="C:\lab\Packed\New Section 1.one" lastModifiedTime="2026-10-01T18:34:32.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{E20A444C-78CD-0999-3DD1-621E4BAED134}{1}{B0}" name="First" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:32.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section><one:SectionGroup name="Group" ID="{0B4B60D5-92AC-47F7-A719-F04AB324F3A3}{1}{B0}" path="C:\lab\Packed\Group" lastModifiedTime="2026-10-01T18:34:32.000Z"><one:Section name="Inner" ID="{EA1DC458-069E-0140-2D8B-26966CF0C773}{1}{B0}" path="C:\lab\Packed\Group\Inner.one" lastModifiedTime="2026-10-01T18:34:32.000Z" color="#8AA8E4"><one:Page ID="{53AFFAB0-4635-0206-3F13-CD76EF44CE0C}{1}{B0}" name="Inside" dateTime="2026-10-01T18:18:04.000Z" lastModifiedTime="2026-10-01T18:34:32.000Z" pageLevel="1"/></one:Section></one:SectionGroup></one:Notebook></one:Notebooks> |
corpus/notebook-package/onenote-unpacked/unpack-dialog.png created| Binary files /dev/null and b/corpus/notebook-package/onenote-unpacked/unpack-dialog.png differ |
corpus/notebook-package/onenote-unpacked/unpacked.png created| Binary files /dev/null and b/corpus/notebook-package/onenote-unpacked/unpacked.png differ |
corpus/notebook-package/save-as/candidate/Inner copy.one created| Binary files /dev/null and b/corpus/notebook-package/save-as/candidate/Inner copy.one differ |
corpus/notebook-package/save-as/candidate/Inside.one created| Binary files /dev/null and b/corpus/notebook-package/save-as/candidate/Inside.one differ |
corpus/notebook-package/save-as/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-113F9C31 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 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/notebook-package/save-as/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-113f9c31", "hostname": "ONE-M6-113F9C31"} |
corpus/notebook-package/save-as/cold/notebook/Inner copy.one created| Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Inner copy.one differ |
corpus/notebook-package/save-as/cold/notebook/Inside.one created| Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Inside.one differ |
corpus/notebook-package/save-as/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Open Notebook.onetoc2 differ |
corpus/notebook-package/save-as/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-113F9C31", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/notebook-package/save-as/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="{FB15A844-FF2A-41CA-9862-38272B19FB14}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T18:43:16.000Z" color="#B49EDE"><one:Section name="Inner copy" ID="{9A0B0136-2B34-0A73-20A7-6DBF7E320E2F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Inner copy.one" lastModifiedTime="2026-10-01T18:43:16.000Z" color="#8AA8E4"><one:Page ID="{BA69ACE8-6668-0F1F-360F-E29D0BE5CA5A}{1}{B0}" name="Inside" dateTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:43:16.000Z" pageLevel="1"/></one:Section><one:Section name="Inside" ID="{501AAD02-A567-026E-1AB0-006CD09D2423}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Inside.one" lastModifiedTime="2026-10-01T18:43:16.000Z" color="#8AA8E4"><one:Page ID="{C6D4AE26-0A29-08F5-0A40-6FBF4F33242B}{1}{B0}" name="Inside" dateTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:43:16.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/notebook-package/save-as/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": "Inside - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/notebook-package/save-as/cold/read/page-000.png created| Binary files /dev/null and b/corpus/notebook-package/save-as/cold/read/page-000.png differ |
corpus/notebook-package/save-as/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="{BA69ACE8-6668-0F1F-360F-E29D0BE5CA5A}{1}{B0}" name="Inside" dateTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:43:16.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:40:13.000Z" objectID="{BA69ACE8-6668-0F1F-360F-E29D0BE5CA5A}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Inside]]></one:T></one:OE></one:Title></one:Page> |
corpus/notebook-package/save-as/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": "Inside - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/notebook-package/save-as/cold/read/page-001.png created| Binary files /dev/null and b/corpus/notebook-package/save-as/cold/read/page-001.png differ |
corpus/notebook-package/save-as/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="{C6D4AE26-0A29-08F5-0A40-6FBF4F33242B}{1}{B0}" name="Inside" dateTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:43:16.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:40:13.000Z" lastModifiedTime="2026-10-01T18:40:13.000Z" objectID="{C6D4AE26-0A29-08F5-0A40-6FBF4F33242B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Inside]]></one:T></one:OE></one:Title></one:Page> |
corpus/notebook-package/save-as/cold/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/notebook-package/save-as/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-notebook-features/saveas-export", | |
| 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/notebook-package/save-as/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/notebook-package/save-as/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/notebook-package/save-as/cold/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Inner copy.one", | |
| 4 | "bytes": 6032, | |
| 5 | "sha256": "48f848eb6d0c1ec03a137763b8595b7b4cb7ee6e0f5f8bdf96e6cb5f5c23be1e", | |
| 6 | "mtime_ns": 1790880013898068009 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Inside.one", | |
| 10 | "bytes": 6032, | |
| 11 | "sha256": "0a311b978c401a5571be844dfd54cddb551d6820eac0b4d72ad590afb563bcb2", | |
| 12 | "mtime_ns": 1790880013898269467 | |
| 13 | } | |
| 14 | ] |
corpus/notebook-package/save-as/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/protected-edit/README.md+2-2| ... | ... | @@ -2,8 +2,8 @@ |
| 2 | 2 | |
| 3 | 3 | `candidate/notebook` is `native-encrypted/encrypted-01` after |
| 4 | 4 | a Rust edit of the unlocked page appended text to the first paragraph and added a |
| 5 | paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key` | |
| 6 | (`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a | |
| 5 | paragraph, exported by the protected writer that `Section::unlock` has since replaced | |
| 6 | (`corpus/protected-sections` gates the current one). `candidate/read` is OneNote 2010's COM read from a | |
| 7 | 7 | fresh clone with an empty cache after unlocking the section in its UI with the |
| 8 | 8 | fixture password (`../native-encrypted/manifest.json`). |
| 9 | 9 |
corpus/protected-sections/README.md created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | # Password-protected sections | |
| 2 | ||
| 3 | OneNote 2010 (14.0.4763.1000, Windows 7 lab) setting, changing and removing a section's | |
| 4 | password, and Snowbound doing the same to a notebook that a fresh OneNote then cold-opens. | |
| 5 | Passwords are fictitious and public ([manifest.json](manifest.json), | |
| 6 | `candidate/passwords.json`). | |
| 7 | ||
| 8 | ## What OneNote does | |
| 9 | ||
| 10 | - **Set, change, remove.** Each writes the section anew: OneNote deletes the file and | |
| 11 | creates one at the same path (a new NTFS file index, its creation time tunnelled), which | |
| 12 | grows through some 30 transactions. Every identity is new: the header's file GUID, the root | |
| 13 | object space, each page space (one fresh GUID for all of them, each its own `n`), and each payload's | |
| 14 | file-data GUID. Each space keeps its labelled revisions, each a checkpoint: the current one | |
| 15 | under roles 1 and 4, the version history under its context. The TOC gains an entry for | |
| 16 | the new identity and keeps the old one (`native/*.one` from `source/synthetic.one`). | |
| 17 | - **The crypto.** Office Agile encryption (MS-OFFCRYPTO 2.3.4.10) inside the | |
| 18 | `ObjectDataEncryptionKeyV2FNDX` container (MS-ONESTORE 2.5.19): the words | |
| 19 | `3, length, 16, length − 16`, version `4.4` with flags `0x40`, then UTF-8 XML with a CRLF | |
| 20 | after the declaration and no data-integrity element. Key data and key encryptor are both | |
| 21 | AES-128, CBC, SHA-1, 16-byte salts; `spinCount` 100000. H0 = SHA-1(salt ‖ UTF-16LE | |
| 22 | password), Hn = SHA-1(LE32(n) ‖ Hn−1); each block key is SHA-1(H ‖ label)[..16] with the | |
| 23 | password salt as IV. The verifier hash is padded with zeros to 32 bytes. A change of | |
| 24 | password draws a new 16-byte content key. Property objects are their reference streams, a | |
| 25 | length, a random IV and CBC of `u16 padding ‖ bytes ‖ random padding`; payloads are CBC, | |
| 26 | IV = SHA-1(key-data salt ‖ LE32(0))[..16], of `u64 length ‖ bytes ‖ random padding`. | |
| 27 | Read-only declarations hash the clear bytes zero-padded to 8. Every revision OneNote | |
| 28 | writes anew names the key; its later dependent revisions do not. | |
| 29 | - **Locking.** Locked, the section shows "This section is password protected. Click here or | |
| 30 | press ENTER to unlock this section." with no page list; the Protected Section dialog says | |
| 31 | "Password is incorrect." Options › Advanced › Passwords: lock after not being worked in for | |
| 32 | 1, 5, 10 (default), 15 or 30 minutes, 1, 2, 4, 8 or 12 hours or 1 day (registry | |
| 33 | `Options\Save\PasswordTimeout`, minutes; it takes effect after a restart); lock on | |
| 34 | navigating away (off); let add-ins reach unlocked sections (on). With a minute set, a | |
| 35 | section idle on screen locked between 72 and 132 seconds. Ctrl+Alt+L and Lock All lock | |
| 36 | every section. Locking and unlocking write nothing. | |
| 37 | - **Search and Tags Summary** leave locked sections out without saying so, and take them in | |
| 38 | once unlocked. | |
| 39 | - **Other writers.** With the section locked, OneNote wrote nothing while another writer | |
| 40 | appended a revision, and showed it once unlocked; unlocked, it showed the next one live. | |
| 41 | When the other writer gave the section a new password (a new file, as above), OneNote | |
| 42 | showed it locked again, refused the old password and took the new one | |
| 43 | (`native/observed/`). Observed on a local folder with one OneNote; two OneNotes on a share | |
| 44 | were not tried. | |
| 45 | ||
| 46 | `native/snowbound-changed-then-onenote-edit.one` is a section Snowbound protected, edited and | |
| 47 | gave another password, after OneNote unlocked it and typed into it. | |
| 48 | ||
| 49 | ## Snowbound | |
| 50 | ||
| 51 | `candidate/notebook` comes from `a_notebook_protected_through_its_sessions` | |
| 52 | (`crates/notebook/tests/protected.rs`, `ONESTORE_PROTECTED_EXPORT`): OneNote's pages imported | |
| 53 | into five sections, each given a password through `Notebook::set_password`; `Edited` edited | |
| 54 | through its session three times, `Changed` given another password and edited, `Removed` | |
| 55 | unprotected, `Conflicted` edited offline by two replicas into conflict pages. | |
| 56 | `tools/native_protected.py` cold-opened it in a fresh clone, unlocked each section in OneNote's | |
| 57 | dialog, typed into `Edited` through COM and read every page (`cold/`, with the notebook | |
| 58 | OneNote left). `tools/test_protected_sections.py` checks both directions without a VM. |
corpus/protected-sections/candidate/notebook/Changed.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Changed.one differ |
corpus/protected-sections/candidate/notebook/Conflicted.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Conflicted.one differ |
corpus/protected-sections/candidate/notebook/Edited.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Edited.one differ |
corpus/protected-sections/candidate/notebook/New Section 1.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/New Section 1.one differ |
corpus/protected-sections/candidate/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Open Notebook.onetoc2 differ |
corpus/protected-sections/candidate/notebook/Protected.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Protected.one differ |
corpus/protected-sections/candidate/notebook/Removed.one created| Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Removed.one differ |
corpus/protected-sections/candidate/passwords.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "Changed.one": "Changed again", | |
| 3 | "Conflicted.one": "Conflicted gate password", | |
| 4 | "Edited.one": "Edited gate password", | |
| 5 | "Protected.one": "Protected gate password", | |
| 6 | "Removed.one": null | |
| 7 | } | |
| \ No newline at end of file |
corpus/protected-sections/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | Fictitious attachment for native corpus. | |
| \ No newline at end of file |
corpus/protected-sections/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-7DD6464F", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/protected-sections/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="{7CC53A9F-3478-4A81-A8E2-7BBED7D263FA}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T08:10:07.000Z" color="#C0A0F0"><one:Section name="New Section 1" ID="{C53E1F32-A810-0492-03B5-66C418CBA7E5}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T08:09:57.000Z" color="#8AA8E4"><one:Page ID="{57937110-5A24-08E4-20EC-210AE732981F}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.000Z" pageLevel="1"/></one:Section><one:Section name="Protected" ID="{115F7C93-2767-0F3E-2907-1F325058528A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Protected.one" lastModifiedTime="2026-10-01T08:09:57.000Z" color="none" encrypted="true" locked="true"/><one:Section name="Edited" ID="{57E01347-A220-0EFA-08F3-0D3CA3CC0379}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Edited.one" lastModifiedTime="2026-10-01T08:09:58.000Z" color="none" encrypted="true" locked="true"/><one:Section name="Changed" ID="{BD36212E-67E1-001A-2596-A965E372BE1D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Changed.one" lastModifiedTime="2026-10-01T08:09:58.000Z" color="none" encrypted="true" locked="true"/><one:Section name="Removed" ID="{1998A859-54A3-09EA-3A8B-A178DA68C65C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Removed.one" lastModifiedTime="2026-10-01T08:10:07.000Z" color="#AB9CB7"><one:Page ID="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/><one:Page ID="{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/><one:Page ID="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.000Z" pageLevel="1"/></one:Section><one:Section name="Conflicted" ID="{4EC67B14-8B14-0FB4-1BCC-77419636D128}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Conflicted.one" lastModifiedTime="2026-10-01T08:10:00.000Z" color="none" encrypted="true" locked="true"/></one:Notebook> |
corpus/protected-sections/cold/before-read/page-000.xml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{49198084-72D5-0478-36CF-554C2E384795}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[<span | |
| 3 | lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi: | |
| 4 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{119}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="13.42771625518799"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{121}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 6 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 8 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 9 | lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{122}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{124}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{125}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{127}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 10 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{128}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\fd0cff9b-6290-4d5b-9bd2-529e3f9b6cda.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{131}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{140}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{132}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{139}{B0}" lastModifiedTime="2026-10-01T08:01:15.000Z"><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{133}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{135}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{136}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{138}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/cold/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="{57937110-5A24-08E4-20EC-210AE732981F}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{57937110-5A24-08E4-20EC-210AE732981F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/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="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/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="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{145}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="187.7184448242187" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{147}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/cold/before-read/payloads.json created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 4 | "name": "fictitious-attachment.txt", | |
| 5 | "object": "{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}", | |
| 6 | "kind": "InsertedFile", | |
| 7 | "page": "{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}", | |
| 8 | "bytes": 43 | |
| 9 | } | |
| 10 | ] | |
| \ No newline at end of file |
corpus/protected-sections/cold/commands.jsonl created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 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-7DD6464F -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 6 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} | |
| 4 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root C:\\one-tests\\runs\\capture -Section \"Changed.one\"", "exit": 0, "stdout": "{BD36212E-67E1-001A-2596-A965E372BE1D}{1}{B0}\r\n", "stderr": "", "error": null} | |
| 5 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root C:\\one-tests\\runs\\capture -Section \"Conflicted.one\"", "exit": 0, "stdout": "{4EC67B14-8B14-0FB4-1BCC-77419636D128}{1}{B0}\r\n", "stderr": "", "error": null} | |
| 6 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root C:\\one-tests\\runs\\capture -Section \"Edited.one\"", "exit": 0, "stdout": "{57E01347-A220-0EFA-08F3-0D3CA3CC0379}{1}{B0}\r\n", "stderr": "", "error": null} | |
| 7 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root C:\\one-tests\\runs\\capture -Section \"Protected.one\"", "exit": 0, "stdout": "{115F7C93-2767-0F3E-2907-1F325058528A}{1}{B0}\r\n", "stderr": "", "error": null} | |
| 8 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root C:\\one-tests\\runs\\capture -Section \"Edited.one\" -Edit", "exit": 0, "stdout": "edited\r\n", "stderr": "", "error": null} | |
| 9 | {"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 10 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7DD6464F -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 6 sections and 16 pages.\r\n", "stderr": "", "error": null} | |
| 11 | {"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/protected-sections/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-7dd6464f", "hostname": "ONE-M6-7DD6464F"} |
corpus/protected-sections/cold/notebook/Changed.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Changed.one differ |
corpus/protected-sections/cold/notebook/Conflicted.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Conflicted.one differ |
corpus/protected-sections/cold/notebook/Edited.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Edited.one differ |
corpus/protected-sections/cold/notebook/New Section 1.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/New Section 1.one differ |
corpus/protected-sections/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Open Notebook.onetoc2 differ |
corpus/protected-sections/cold/notebook/Protected.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Protected.one differ |
corpus/protected-sections/cold/notebook/Removed.one created| Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Removed.one differ |
corpus/protected-sections/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | Fictitious attachment for native corpus. | |
| \ No newline at end of file |
corpus/protected-sections/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-7DD6464F", | |
| 5 | "cold": false, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/protected-sections/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="{7CC53A9F-3478-4A81-A8E2-7BBED7D263FA}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T08:12:11.000Z" color="#C0A0F0" isCurrentlyViewed="true"><one:Section name="New Section 1" ID="{C53E1F32-A810-0492-03B5-66C418CBA7E5}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T08:11:10.000Z" color="#8AA8E4"><one:Page ID="{57937110-5A24-08E4-20EC-210AE732981F}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:10.000Z" pageLevel="1"/></one:Section><one:Section name="Protected" ID="{115F7C93-2767-0F3E-2907-1F325058528A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Protected.one" lastModifiedTime="2026-10-01T08:12:11.000Z" color="#F6B078" encrypted="true" isCurrentlyViewed="true"><one:Page ID="{AD541964-85FE-0949-3238-7546A18D3E8C}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:12:01.000Z" pageLevel="1" isCurrentlyViewed="true"/><one:Page ID="{866584A2-177A-0F98-3D57-EE8DE1960AF4}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.000Z" pageLevel="1"/><one:Page ID="{38BBACF5-2699-0ABF-05E3-BC49FCE0A02B}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.000Z" pageLevel="1"/></one:Section><one:Section name="Edited" ID="{57E01347-A220-0EFA-08F3-0D3CA3CC0379}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Edited.one" lastModifiedTime="2026-10-01T08:12:11.000Z" color="#D5A4BB" encrypted="true"><one:Page ID="{D3F159CF-E14F-0BDB-0352-1DCF71E233D9}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:50.000Z" pageLevel="1"/><one:Page ID="{09B82703-E16E-0E2C-0350-7B8F0E453BD8}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" pageLevel="1"/><one:Page ID="{568865D8-35DC-0BFB-15EE-BC57E0D0FCE4}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/></one:Section><one:Section name="Changed" ID="{BD36212E-67E1-001A-2596-A965E372BE1D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Changed.one" lastModifiedTime="2026-10-01T08:11:28.000Z" color="#9BBBD2" encrypted="true"><one:Page ID="{42CC9CF0-69A9-0CA2-38B7-6970B4B0A0D8}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:18.000Z" pageLevel="1"/><one:Page ID="{FD77E918-CDA0-0CA5-1038-F63ED915C073}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/><one:Page ID="{C2954519-DDE7-0BF5-2389-99ACE5D37E39}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/></one:Section><one:Section name="Removed" ID="{1998A859-54A3-09EA-3A8B-A178DA68C65C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Removed.one" lastModifiedTime="2026-10-01T08:10:07.000Z" color="#AB9CB7"><one:Page ID="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/><one:Page ID="{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"/><one:Page ID="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.000Z" pageLevel="1"/></one:Section><one:Section name="Conflicted" ID="{4EC67B14-8B14-0FB4-1BCC-77419636D128}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Conflicted.one" lastModifiedTime="2026-10-01T08:11:38.000Z" color="#E8D199" encrypted="true"><one:Page ID="{401B1B42-C2C3-0280-23E0-14EE9B529770}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:37.000Z" pageLevel="1"/><one:Page ID="{0A6963A0-3B14-022B-3322-0716C2D41936}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.000Z" pageLevel="1"/><one:Page ID="{AD9998F3-4D35-085E-3481-5F89FE8B335C}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:10:00.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/protected-sections/cold/read/page-000.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="{09B82703-E16E-0E2C-0350-7B8F0E453BD8}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:12:05.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="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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{09B82703-E16E-0E2C-0350-7B8F0E453BD8}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span | |
| 3 | style='font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 4 | style='font-family:SimSun' lang=en-US>東京</span><span style='font-family:Calibri' | |
| 5 | lang=en-US>, </span><span style='font-family:Calibri;direction:rtl;unicode-bidi: | |
| 6 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{41}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="89.43936920166015"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{43}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 8 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 9 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 10 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 11 | lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial' | |
| 12 | lang=en-US> Edited first under the key. Edited second under the key. Edited third under the key.</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" creationTime="2026-10-01T08:12:05.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{39}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[Typed by OneNote 2010 under the key.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{44}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:12:05.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{46}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{47}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{49}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 13 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{50}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{52}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Temp\OneNote\14.0\DNT\2d65e77f-f5c3-46ff-8cf6-1a1836444487.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{53}{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-10-01T08:12:05.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{69}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{70}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{71}{B0}" lastModifiedTime="2026-10-01T08:12:05.000Z"><one:Cell lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{72}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-10-01T08:12:05.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{57}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{0605E598-31C3-4B63-8A78-B65B222B75EC}{79}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-10-01T08:12:05.000Z" lastModifiedTime="2026-10-01T08:12:05.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{60}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/cold/read/page-001.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="{0A6963A0-3B14-022B-3322-0716C2D41936}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{0A6963A0-3B14-022B-3322-0716C2D41936}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[<span | |
| 3 | lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi: | |
| 4 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{158}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="25.71677017211914"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{160}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 6 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 8 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 9 | lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial' | |
| 10 | lang=en-US> Ours offline.</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{161}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{163}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{164}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{166}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 11 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{167}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{169}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Temp\OneNote\14.0\DNT\fbd3fb23-42ff-4ef8-8778-0a220556aa22.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{170}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{179}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{171}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{178}{B0}" lastModifiedTime="2026-10-01T08:01:15.000Z"><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{172}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{174}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{175}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{177}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/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="{38BBACF5-2699-0ABF-05E3-BC49FCE0A02B}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{38BBACF5-2699-0ABF-05E3-BC49FCE0A02B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="187.7184448242187" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{30}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/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="{401B1B42-C2C3-0280-23E0-14EE9B529770}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:37.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:30.000Z" objectID="{401B1B42-C2C3-0280-23E0-14EE9B529770}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/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="{42CC9CF0-69A9-0CA2-38B7-6970B4B0A0D8}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:18.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:17.000Z" objectID="{42CC9CF0-69A9-0CA2-38B7-6970B4B0A0D8}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/read/page-005.xml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{49198084-72D5-0478-36CF-554C2E384795}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[<span | |
| 3 | lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi: | |
| 4 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{119}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="13.42771625518799"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{121}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 6 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 8 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 9 | lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{122}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{124}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{125}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{127}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 10 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{128}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\fd0cff9b-6290-4d5b-9bd2-529e3f9b6cda.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{131}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{140}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{132}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{139}{B0}" lastModifiedTime="2026-10-01T08:01:15.000Z"><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{133}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{135}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{136}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{138}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/cold/read/page-006.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="{568865D8-35DC-0BFB-15EE-BC57E0D0FCE4}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{568865D8-35DC-0BFB-15EE-BC57E0D0FCE4}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{67}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="352.7999572753906" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{494711AC-8C21-0B35-201D-C281CCD3535F}{69}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden Edited first under the key. Edited second under the key. Edited third under the key.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/cold/read/page-007.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="{57937110-5A24-08E4-20EC-210AE732981F}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:10.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{57937110-5A24-08E4-20EC-210AE732981F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/read/page-008.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="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{7AAD5C79-6EC7-0F05-3672-4EC0C9C01A89}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/read/page-009.xml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{866584A2-177A-0F98-3D57-EE8DE1960AF4}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:09:57.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{866584A2-177A-0F98-3D57-EE8DE1960AF4}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[<span | |
| 3 | lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi: | |
| 4 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{2}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="13.42771625518799"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{4}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 6 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 8 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 9 | lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{5}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{7}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{8}{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-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{10}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 10 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{11}{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-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{13}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Temp\OneNote\14.0\DNT\da618bcd-c661-4692-8070-604e49978754.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{14}{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-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{23}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{15}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{22}{B0}" lastModifiedTime="2026-10-01T08:01:14.000Z"><one:Cell lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{16}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{18}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{19}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:01:14.000Z" objectID="{2258725C-FAAF-0704-1006-EE69B8759811}{21}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/cold/read/page-010.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="{AD541964-85FE-0949-3238-7546A18D3E8C}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:12:01.000Z" pageLevel="1" isCurrentlyViewed="true"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:54.000Z" objectID="{AD541964-85FE-0949-3238-7546A18D3E8C}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/read/page-011.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="{AD9998F3-4D35-085E-3481-5F89FE8B335C}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:10:00.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{AD9998F3-4D35-085E-3481-5F89FE8B335C}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{184}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="245.4846649169922" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{186}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden Ours offline.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/cold/read/page-012.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="{C2954519-DDE7-0BF5-2389-99ACE5D37E39}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{C2954519-DDE7-0BF5-2389-99ACE5D37E39}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{106}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="352.7999572753906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{108}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden Written under the changed password.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/cold/read/page-013.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="{D3F159CF-E14F-0BDB-0352-1DCF71E233D9}{1}{B0}" name="Gate" dateTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:50.000Z" pageLevel="1"><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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:14.000Z" lastModifiedTime="2026-10-01T08:11:41.000Z" objectID="{D3F159CF-E14F-0BDB-0352-1DCF71E233D9}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Gate]]></one:T></one:OE></one:Title></one:Page> |
corpus/protected-sections/cold/read/page-014.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="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{1}{B0}" name="Second page" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:59.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="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{E8B21F8D-C90C-0CF0-3B52-196CDF362233}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Second page]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{145}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="187.7184448242187" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{147}{B0}" alignment="left" quickStyleIndex="1"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-01T05:51:36.000Z"/><one:T><![CDATA[Secret words: secretword hidden]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/protected-sections/cold/read/page-015.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="{FD77E918-CDA0-0CA5-1038-F63ED915C073}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:09:58.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.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 showDate="false" showTime="false" lang="en-US"><one:OE author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{FD77E918-CDA0-0CA5-1038-F63ED915C073}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[<span | |
| 3 | lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi: | |
| 4 | embed' lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:Title><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{80}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="38.00582504272461"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{82}{B0}" alignment="left" quickStyleIndex="1" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 6 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 7 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 8 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 9 | lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial' | |
| 10 | lang=en-US> Written under the changed password.</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{83}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{85}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{86}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{88}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | |
| 11 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{89}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{91}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Temp\OneNote\14.0\DNT\6bdb5d43-fab2-42c6-bb1d-5efb7755cbab.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="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{92}{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-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{101}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{93}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{100}{B0}" lastModifiedTime="2026-10-01T08:01:15.000Z"><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{94}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{96}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{97}{B0}" lastModifiedByInitials="ST"><one:OEChildren><one:OE creationTime="2026-10-01T08:01:15.000Z" lastModifiedTime="2026-10-01T08:01:15.000Z" objectID="{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{99}{B0}" alignment="left" quickStyleIndex="1"><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/protected-sections/cold/read/payloads.json created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 4 | "name": "fictitious-attachment.txt", | |
| 5 | "object": "{494711AC-8C21-0B35-201D-C281CCD3535F}{52}{B0}", | |
| 6 | "kind": "InsertedFile", | |
| 7 | "page": "{09B82703-E16E-0E2C-0350-7B8F0E453BD8}{1}{B0}", | |
| 8 | "bytes": 43 | |
| 9 | }, | |
| 10 | { | |
| 11 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 12 | "name": "fictitious-attachment.txt", | |
| 13 | "object": "{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{169}{B0}", | |
| 14 | "kind": "InsertedFile", | |
| 15 | "page": "{0A6963A0-3B14-022B-3322-0716C2D41936}{1}{B0}", | |
| 16 | "bytes": 43 | |
| 17 | }, | |
| 18 | { | |
| 19 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 20 | "name": "fictitious-attachment.txt", | |
| 21 | "object": "{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}", | |
| 22 | "kind": "InsertedFile", | |
| 23 | "page": "{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}", | |
| 24 | "bytes": 43 | |
| 25 | }, | |
| 26 | { | |
| 27 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 28 | "name": "fictitious-attachment.txt", | |
| 29 | "object": "{2258725C-FAAF-0704-1006-EE69B8759811}{13}{B0}", | |
| 30 | "kind": "InsertedFile", | |
| 31 | "page": "{866584A2-177A-0F98-3D57-EE8DE1960AF4}{1}{B0}", | |
| 32 | "bytes": 43 | |
| 33 | }, | |
| 34 | { | |
| 35 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 36 | "name": "fictitious-attachment.txt", | |
| 37 | "object": "{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{91}{B0}", | |
| 38 | "kind": "InsertedFile", | |
| 39 | "page": "{FD77E918-CDA0-0CA5-1038-F63ED915C073}{1}{B0}", | |
| 40 | "bytes": 43 | |
| 41 | } | |
| 42 | ] | |
| \ No newline at end of file |
corpus/protected-sections/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-locked/gate/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/protected-sections/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/protected-sections/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/protected-sections/cold/source.json created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Changed.one", | |
| 4 | "bytes": 26248, | |
| 5 | "sha256": "c6dfb825da16bd399d9c08e59b9fce17ce6d31000ac9dcb35fb88348abcc25f6", | |
| 6 | "mtime_ns": 1790841675948190811 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Conflicted.one", | |
| 10 | "bytes": 44384, | |
| 11 | "sha256": "53bf55828c7d7b3e27812bb8a37a58826019d363982b460eb20b99038f077e2e", | |
| 12 | "mtime_ns": 1790841676132162946 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "Edited.one", | |
| 16 | "bytes": 28952, | |
| 17 | "sha256": "36fa95d9944b91cc702b2834a8938fdf4db03cb4cc4eea59ad4118cc0045fe1a", | |
| 18 | "mtime_ns": 1790841675847211065 | |
| 19 | }, | |
| 20 | { | |
| 21 | "path": "New Section 1.one", | |
| 22 | "bytes": 6064, | |
| 23 | "sha256": "7c59a07e40a6162d9392b069dfec88822b0ba540e671ed1199582e89c958013b", | |
| 24 | "mtime_ns": 1790841674768801766 | |
| 25 | }, | |
| 26 | { | |
| 27 | "path": "Open Notebook.onetoc2", | |
| 28 | "bytes": 8520, | |
| 29 | "sha256": "4308d8a6273eb58cd0089cbc056d4cdb5f5767e8f61a1173bbb17a8fb853e6ed", | |
| 30 | "mtime_ns": 1790841675974244740 | |
| 31 | }, | |
| 32 | { | |
| 33 | "path": "Protected.one", | |
| 34 | "bytes": 22976, | |
| 35 | "sha256": "d1ec539f23bd1b7355c3e9891735fc23a3f27b65d304c6cfd19eed18f0fae857", | |
| 36 | "mtime_ns": 1790841675582959551 | |
| 37 | }, | |
| 38 | { | |
| 39 | "path": "Removed.one", | |
| 40 | "bytes": 19656, | |
| 41 | "sha256": "4db34bfd031431a02909183e75c0f836eb10e43aef0842434b44ec801735a232", | |
| 42 | "mtime_ns": 1790841675961507402 | |
| 43 | } | |
| 44 | ] |
corpus/protected-sections/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/protected-sections/cold/unlock-Changed.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "Gate - Microsoft OneNote", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Gate - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/protected-sections/cold/unlock-Changed.png created| Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Changed.png differ |
corpus/protected-sections/cold/unlock-Conflicted.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "Gate - Microsoft OneNote", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Gate - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/protected-sections/cold/unlock-Conflicted.png created| Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Conflicted.png differ |
corpus/protected-sections/cold/unlock-Edited.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "Gate - Microsoft OneNote", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Gate - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/protected-sections/cold/unlock-Edited.png created| Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Edited.png differ |
corpus/protected-sections/cold/unlock-Protected.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "Gate - Microsoft OneNote", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Gate - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/protected-sections/cold/unlock-Protected.png created| Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Protected.png differ |
corpus/protected-sections/manifest.json created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | { | |
| 2 | "native_build": "14.0.4763.1000", | |
| 3 | "source": "source/synthetic.one", | |
| 4 | "native": { | |
| 5 | "protected.one": "lab-pass-1", | |
| 6 | "changed.one": "lab-pass-2", | |
| 7 | "removed.one": null, | |
| 8 | "snowbound-changed-then-onenote-edit.one": "snow-pass-2" | |
| 9 | } | |
| 10 | } |
corpus/protected-sections/native/changed.one created| Binary files /dev/null and b/corpus/protected-sections/native/changed.one differ |
corpus/protected-sections/native/observed/onenote-change-password.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-change-password.png differ |
corpus/protected-sections/native/observed/onenote-locked-page.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-locked-page.png differ |
corpus/protected-sections/native/observed/onenote-options-lock-after.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-options-lock-after.png differ |
corpus/protected-sections/native/observed/onenote-options-passwords.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-options-passwords.png differ |
corpus/protected-sections/native/observed/onenote-pane-protected.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-pane-protected.png differ |
corpus/protected-sections/native/observed/onenote-relocks-after-remote-password-change.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-relocks-after-remote-password-change.png differ |
corpus/protected-sections/native/observed/onenote-remove-password.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-remove-password.png differ |
corpus/protected-sections/native/observed/onenote-search-locked.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-search-locked.png differ |
corpus/protected-sections/native/observed/onenote-set-password.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-set-password.png differ |
corpus/protected-sections/native/observed/onenote-tags-locked.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-tags-locked.png differ |
corpus/protected-sections/native/observed/onenote-tags-unlocked.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-tags-unlocked.png differ |
corpus/protected-sections/native/observed/onenote-unlock-dialog.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlock-dialog.png differ |
corpus/protected-sections/native/observed/onenote-unlock-wrong.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlock-wrong.png differ |
corpus/protected-sections/native/observed/onenote-unlocked-shows-locked-append.png created| Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlocked-shows-locked-append.png differ |
corpus/protected-sections/native/protected.one created| Binary files /dev/null and b/corpus/protected-sections/native/protected.one differ |
corpus/protected-sections/native/removed.one created| Binary files /dev/null and b/corpus/protected-sections/native/removed.one differ |
corpus/protected-sections/native/snowbound-changed-then-onenote-edit.one created| Binary files /dev/null and b/corpus/protected-sections/native/snowbound-changed-then-onenote-edit.one differ |
corpus/protected-sections/source/synthetic.one created| Binary files /dev/null and b/corpus/protected-sections/source/synthetic.one differ |
corpus/recycle-bin-view/README.md created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | # Recycle bin view | |
| 2 | ||
| 3 | OneNote 2010's Notebook Recycle Bin (Share, observed in a lab clone on 2026-10-01): the bin | |
| 4 | opens as its own row of tabs, Deleted Pages then each deleted section, headed `Recycle Bin for | |
| 5 | "Notebook":`, read-only under a bar ("To restore a page or section, right-click and move it out | |
| 6 | of this Recycle Bin. Content here is deleted after 60 days."). A page's Move or Copy takes it to | |
| 7 | any section; a section's moves it into the notebook or a group, its file moving there. Empty | |
| 8 | Recycle Bin asks "Are you sure you want to empty the Recycle Bin for this notebook?", deletes | |
| 9 | Deleted Pages' pages in one revision and each binned section's file, and leaves the bin's TOC | |
| 10 | byte for byte, its entries naming files no longer there. | |
| 11 | ||
| 12 | `candidate/before` is the notebook | |
| 13 | `pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them` in | |
| 14 | `crates/snowbound/src/recycle.rs` exports with `SNOWBOUND_RECYCLE_EXPORT`: three pages and five | |
| 15 | sections deleted into the bin. `candidate/after` is it once Snowbound restored a page (its | |
| 16 | identity kept), copied one out (a new identity), deleted one for good, restored a section to the | |
| 17 | notebook and emptied the bin. `cold/before` and `cold/after` are fresh OneNote 2010 reads of | |
| 18 | each: everything where it was meant to be, every file left as written. | |
| 19 | `tools/test_recycle_bin_view.py` checks them without a VM. |
corpus/recycle-bin-view/candidate/after/New Section 1.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/New Section 1.one differ |
corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/OneNote_DeletedPages.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/OneNote_DeletedPages.one differ |
corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/candidate/after/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/candidate/after/Restored.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/Restored.one differ |
corpus/recycle-bin-view/candidate/before/New Section 1.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/New Section 1.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Back.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Back.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Copied.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Copied.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Emptied.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Emptied.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/OneNote_DeletedPages.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/OneNote_DeletedPages.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Purged.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Purged.one differ |
corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Restored.one created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Restored.one differ |
corpus/recycle-bin-view/candidate/before/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/cold/after/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-4293BB43 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 3 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/recycle-bin-view/cold/after/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-4293bb43", "hostname": "ONE-M6-4293BB43"} |
corpus/recycle-bin-view/cold/after/notebook/New Section 1.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/New Section 1.one differ |
corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ |
corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/cold/after/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/cold/after/notebook/Restored.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/Restored.one differ |
corpus/recycle-bin-view/cold/after/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-4293BB43", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/recycle-bin-view/cold/after/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="{76F9D89A-6ACD-4D51-AD4D-4D2DBAC309FE}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T18:21:35.000Z" color="#91BAAE"><one:Section name="New Section 1" ID="{6BD732FC-BF52-0DC5-14F8-6D50781E27A5}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T18:21:35.000Z" color="#8AA8E4"><one:Page ID="{05803487-D078-0A65-1688-0D3B4087E8CA}{1}{B0}" name="Kept" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"/><one:Page ID="{06E31563-7040-06C9-33E3-FFA3057976AE}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"/><one:Page ID="{5CD9C16B-9A3C-0A55-09DE-C23A75817EB3}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:42.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"/></one:Section><one:Section name="Restored" ID="{EA93B769-F491-00E2-3223-16A4F70D9EB2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Restored.one" lastModifiedTime="2026-10-01T18:21:35.000Z" color="#AB9CB7"><one:Page ID="{3BFFF889-6125-0FB3-302D-B9B1F14DC255}{1}{B0}" name="In a restored section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="OneNote_RecycleBin" ID="{C7D66C54-52B2-4F80-BABE-239B184B4043}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-10-01T18:21:35.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{D7F0F364-9779-0507-32C9-F8C73AA06D30}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-10-01T18:21:35.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"/></one:SectionGroup></one:Notebook> |
corpus/recycle-bin-view/cold/after/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": "Kept - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/after/read/page-000.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-000.png differ |
corpus/recycle-bin-view/cold/after/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="{05803487-D078-0A65-1688-0D3B4087E8CA}{1}{B0}" name="Kept" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{05803487-D078-0A65-1688-0D3B4087E8CA}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/after/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": "Restored page - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/after/read/page-001.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-001.png differ |
corpus/recycle-bin-view/cold/after/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="{06E31563-7040-06C9-33E3-FFA3057976AE}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{06E31563-7040-06C9-33E3-FFA3057976AE}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Restored page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/after/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": "In a restored section - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/after/read/page-002.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-002.png differ |
corpus/recycle-bin-view/cold/after/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="{3BFFF889-6125-0FB3-302D-B9B1F14DC255}{1}{B0}" name="In a restored section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{3BFFF889-6125-0FB3-302D-B9B1F14DC255}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[In a restored section]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/after/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": "Copied page - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/after/read/page-003.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-003.png differ |
corpus/recycle-bin-view/cold/after/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="{5CD9C16B-9A3C-0A55-09DE-C23A75817EB3}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:42.000Z" lastModifiedTime="2026-10-01T18:21:35.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:42.000Z" lastModifiedTime="2026-10-01T18:15:42.000Z" objectID="{5CD9C16B-9A3C-0A55-09DE-C23A75817EB3}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Copied page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/after/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/after/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-notebook-features/recycle-export/after", | |
| 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/recycle-bin-view/cold/after/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/recycle-bin-view/cold/after/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/recycle-bin-view/cold/after/source.json created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "New Section 1.one", | |
| 4 | "bytes": 10768, | |
| 5 | "sha256": "dba689859ca9223cc7f7280b8126df9dcfa8bad9146237a2609d69cc1fc014d9", | |
| 6 | "mtime_ns": 1790878542932303216 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "OneNote_RecycleBin/OneNote_DeletedPages.one", | |
| 10 | "bytes": 14816, | |
| 11 | "sha256": "bb80046a0205e1aac6ac1d6b04b50285921860880a3da23b26832a0582391979", | |
| 12 | "mtime_ns": 1790878543094303021 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "OneNote_RecycleBin/Open Notebook.onetoc2", | |
| 16 | "bytes": 6440, | |
| 17 | "sha256": "b932bfa563549a224ab8f3f950d4b23c0e74fbdd56d2ba31b1296ee33495c2c3", | |
| 18 | "mtime_ns": 1790878543072446775 | |
| 19 | }, | |
| 20 | { | |
| 21 | "path": "Open Notebook.onetoc2", | |
| 22 | "bytes": 11160, | |
| 23 | "sha256": "00509e99899c98ce844e32441563d64714964d0d1f4b976a1e1e55e6558a5405", | |
| 24 | "mtime_ns": 1790878543056292320 | |
| 25 | }, | |
| 26 | { | |
| 27 | "path": "Restored.one", | |
| 28 | "bytes": 6120, | |
| 29 | "sha256": "79fbc722219346d2b4359b4bdb3efd60fed68c7953bdf76354501a0f8e614a31", | |
| 30 | "mtime_ns": 1790878543039977656 | |
| 31 | } | |
| 32 | ] |
corpus/recycle-bin-view/cold/after/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/recycle-bin-view/cold/before/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-9027D2D6 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 7 sections and 9 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/recycle-bin-view/cold/before/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-9027d2d6", "hostname": "ONE-M6-9027D2D6"} |
corpus/recycle-bin-view/cold/before/notebook/New Section 1.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/New Section 1.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Back.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Back.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Copied.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Copied.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Emptied.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Emptied.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Purged.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Purged.one differ |
corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Restored.one created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Restored.one differ |
corpus/recycle-bin-view/cold/before/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/Open Notebook.onetoc2 differ |
corpus/recycle-bin-view/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-9027D2D6", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/recycle-bin-view/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="{C78AFCF0-C6DF-4ACC-9020-26F94CA875CD}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T18:21:23.000Z" color="#91BAAE"><one:Section name="New Section 1" ID="{098C414D-13F1-009A-1B6A-A0B9D0B23C98}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T18:21:22.000Z" color="#8AA8E4"><one:Page ID="{67DB4736-7CDB-073A-191A-C0D2E82BF3F7}{1}{B0}" name="Kept" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="OneNote_RecycleBin" ID="{4E42721F-8226-45C2-A59B-AA42C1F92DBE}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-10-01T18:21:23.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{2ED86955-750C-069F-3E6D-C6729A01AC34}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-10-01T18:21:22.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"><one:Page ID="{ABB28566-9F14-06B7-3F28-CBDF67ABF9D3}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/><one:Page ID="{59471A56-FDDA-0D18-37F5-95217763D940}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/><one:Page ID="{B568BF8F-B87D-0D3F-2987-840A08009439}{1}{B0}" name="Purged page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section><one:Section name="Back" ID="{97A5C2F5-1E1A-0F91-2C6C-0F617B9D3912}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Back.one" lastModifiedTime="2026-10-01T18:21:22.000Z" color="#F6B078" isInRecycleBin="true"><one:Page ID="{DF1F1773-B2EB-0A47-2E5D-62113163A07D}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section><one:Section name="Copied" ID="{D89397BE-9E4D-0A32-39A7-CE5784AA206D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Copied.one" lastModifiedTime="2026-10-01T18:21:22.000Z" color="#D5A4BB" isInRecycleBin="true"><one:Page ID="{1D74A3C3-0437-0C0E-1994-AE318AC0DFA8}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section><one:Section name="Purged" ID="{DCBE631D-650B-0AC5-3F1F-F6D1005C36BB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Purged.one" lastModifiedTime="2026-10-01T18:21:22.000Z" color="#9BBBD2" isInRecycleBin="true"><one:Page ID="{EA520682-45BC-037F-12A9-00F253D4E119}{1}{B0}" name="Purged page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section><one:Section name="Restored" ID="{D7258426-D0B5-099C-02DA-D2EE3C6DDAD8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Restored.one" lastModifiedTime="2026-10-01T18:21:23.000Z" color="#AB9CB7" isInRecycleBin="true"><one:Page ID="{0649CBC6-4501-06CD-00D4-7DFB3A2D863F}{1}{B0}" name="In a restored section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:23.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section><one:Section name="Emptied" ID="{DD07088D-F532-0FD7-16D2-26D983D5DC4F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Emptied.one" lastModifiedTime="2026-10-01T18:21:23.000Z" color="#E8D199" isInRecycleBin="true"><one:Page ID="{8363C22F-98C6-0EDB-0202-4BADA1ADC80E}{1}{B0}" name="In an emptied section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:23.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section></one:SectionGroup></one:Notebook> |
corpus/recycle-bin-view/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": "In a restored section (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-000.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-000.png differ |
corpus/recycle-bin-view/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="{0649CBC6-4501-06CD-00D4-7DFB3A2D863F}{1}{B0}" name="In a restored section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:23.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{0649CBC6-4501-06CD-00D4-7DFB3A2D863F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[In a restored section]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/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": "Copied page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-001.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-001.png differ |
corpus/recycle-bin-view/cold/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="{1D74A3C3-0437-0C0E-1994-AE318AC0DFA8}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{1D74A3C3-0437-0C0E-1994-AE318AC0DFA8}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Copied page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/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": "Copied page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-002.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-002.png differ |
corpus/recycle-bin-view/cold/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="{59471A56-FDDA-0D18-37F5-95217763D940}{1}{B0}" name="Copied page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{59471A56-FDDA-0D18-37F5-95217763D940}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Copied page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/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": "Kept - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-003.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-003.png differ |
corpus/recycle-bin-view/cold/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="{67DB4736-7CDB-073A-191A-C0D2E82BF3F7}{1}{B0}" name="Kept" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{67DB4736-7CDB-073A-191A-C0D2E82BF3F7}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/page-004.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "In an emptied section (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-004.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-004.png differ |
corpus/recycle-bin-view/cold/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="{8363C22F-98C6-0EDB-0202-4BADA1ADC80E}{1}{B0}" name="In an emptied section" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:23.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{8363C22F-98C6-0EDB-0202-4BADA1ADC80E}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[In an emptied section]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/page-005.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": "Restored page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-005.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-005.png differ |
corpus/recycle-bin-view/cold/before/read/page-005.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="{ABB28566-9F14-06B7-3F28-CBDF67ABF9D3}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{ABB28566-9F14-06B7-3F28-CBDF67ABF9D3}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Restored page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/page-006.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": "Purged page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-006.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-006.png differ |
corpus/recycle-bin-view/cold/before/read/page-006.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="{B568BF8F-B87D-0D3F-2987-840A08009439}{1}{B0}" name="Purged page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{B568BF8F-B87D-0D3F-2987-840A08009439}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Purged page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/page-007.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": "Restored page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-007.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-007.png differ |
corpus/recycle-bin-view/cold/before/read/page-007.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="{DF1F1773-B2EB-0A47-2E5D-62113163A07D}{1}{B0}" name="Restored page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{DF1F1773-B2EB-0A47-2E5D-62113163A07D}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Restored page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/page-008.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": "Purged page (Read-Only - Recycle Bin) - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/read/page-008.png created| Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-008.png differ |
corpus/recycle-bin-view/cold/before/read/page-008.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="{EA520682-45BC-037F-12A9-00F253D4E119}{1}{B0}" name="Purged page" dateTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:21:22.000Z" pageLevel="1" isInRecycleBin="true"><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="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T18:15:36.000Z" lastModifiedTime="2026-10-01T18:15:36.000Z" objectID="{EA520682-45BC-037F-12A9-00F253D4E119}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Purged page]]></one:T></one:OE></one:Title></one:Page> |
corpus/recycle-bin-view/cold/before/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/recycle-bin-view/cold/before/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-notebook-features/recycle-export/before", | |
| 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/recycle-bin-view/cold/before/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/recycle-bin-view/cold/before/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/recycle-bin-view/cold/before/source.json created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "New Section 1.one", | |
| 4 | "bytes": 6024, | |
| 5 | "sha256": "6510443f5eaac454107799ce05166f1481397a2ade00105a810a4922f1a1d1a5", | |
| 6 | "mtime_ns": 1790878536381575386 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "OneNote_RecycleBin/Back.one", | |
| 10 | "bytes": 6072, | |
| 11 | "sha256": "7456ebd650898521e31b0c575e95d9247c7d3513dac736c0fd260996840089f4", | |
| 12 | "mtime_ns": 1790878536887022715 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "OneNote_RecycleBin/Copied.one", | |
| 16 | "bytes": 6072, | |
| 17 | "sha256": "32d49b0a546b63f785927beab2c2322e7a099114a7a14df20387afe08de7202d", | |
| 18 | "mtime_ns": 1790878536938612540 | |
| 19 | }, | |
| 20 | { | |
| 21 | "path": "OneNote_RecycleBin/Emptied.one", | |
| 22 | "bytes": 6120, | |
| 23 | "sha256": "f9023245ad7907bfe5146a8af40e8a2898b7ced31eb68d8da931986c3bd61b62", | |
| 24 | "mtime_ns": 1790878537062700352 | |
| 25 | }, | |
| 26 | { | |
| 27 | "path": "OneNote_RecycleBin/OneNote_DeletedPages.one", | |
| 28 | "bytes": 10816, | |
| 29 | "sha256": "1290e82b1827fd302cd19fe007eff5aef8d7d9ed577d830bdb664a495249b3fb", | |
| 30 | "mtime_ns": 1790878536875973134 | |
| 31 | }, | |
| 32 | { | |
| 33 | "path": "OneNote_RecycleBin/Open Notebook.onetoc2", | |
| 34 | "bytes": 5880, | |
| 35 | "sha256": "95df8154a57d6d202e75fa2c9514ab89ee7bb232ffc2865e24c216c6ebb65b07", | |
| 36 | "mtime_ns": 1790878537079049557 | |
| 37 | }, | |
| 38 | { | |
| 39 | "path": "OneNote_RecycleBin/Purged.one", | |
| 40 | "bytes": 6072, | |
| 41 | "sha256": "0f62253a5ef9b41cfbb8246dc97e8d1968789d5a1e035b9f48b803d8b7a8b44e", | |
| 42 | "mtime_ns": 1790878536980735991 | |
| 43 | }, | |
| 44 | { | |
| 45 | "path": "OneNote_RecycleBin/Restored.one", | |
| 46 | "bytes": 6120, | |
| 47 | "sha256": "7d1487ed8c558adc48ffc878c1d9f73bc1ff00fa03e71671d478cc45bfe9a6e9", | |
| 48 | "mtime_ns": 1790878537021413776 | |
| 49 | }, | |
| 50 | { | |
| 51 | "path": "Open Notebook.onetoc2", | |
| 52 | "bytes": 10656, | |
| 53 | "sha256": "812827df8f4cb6d2425db9453ee2b93e4b600b41c858c38d28bc9b5e879c00f2", | |
| 54 | "mtime_ns": 1790878537095054096 | |
| 55 | } | |
| 56 | ] |
corpus/recycle-bin-view/cold/before/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/rtl-page/README.md created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | # Editing a right-to-left page | |
| 2 | ||
| 3 | OneNote 2010 keeps a right-to-left page's positions in the same left-to-right frame | |
| 4 | as any page, from a margin origin that `corpus/m6/native-page-direction-03` stores at | |
| 5 | x 10763.25: native XML x is stored x minus the origin minus 36. It opens the page | |
| 6 | scrolled to the right end of its content and keeps the view's right edge when the | |
| 7 | window resizes. A click on blank page puts an outline's left edge on the grid | |
| 8 | anchored at the margin origin, as on any page; only an outline laid out wider than | |
| 9 | its stored width keeps its right edge. A new drawing stores page-frame strokes with | |
| 10 | a zero offset (Win7 lab, 2026-10-01). | |
| 11 | ||
| 12 | `candidate/` is the output of `crates/canvas/tests/rtl_page.rs` on that page through | |
| 13 | the page view: it opens at the drawings' right edge, a click and typing add an outline, | |
| 14 | a pen stroke adds a drawing, and a header drag moves the positioned outline two grid | |
| 15 | columns right, each published as a revision. `cold/` is a fresh OneNote 2010 read of | |
| 16 | it: the new outline at x 126, the moved one at 180, the stroke where the pen went. | |
| 17 | `tools/test_rtl_page.py` checks this without a VM. Regenerate with | |
| 18 | `SNOWBOUND_RTL_EXPORT` set to a new absolute directory while running the test, then | |
| 19 | cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. |
corpus/rtl-page/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/rtl-page/candidate/Open Notebook.onetoc2 differ |
corpus/rtl-page/candidate/synthetic.one created| Binary files /dev/null and b/corpus/rtl-page/candidate/synthetic.one differ |
corpus/rtl-page/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-BE5B6549 -ExpectedPages 1 -Pdf -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/rtl-page/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-be5b6549", "hostname": "ONE-M6-BE5B6549"} |
corpus/rtl-page/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/rtl-page/cold/notebook/Open Notebook.onetoc2 differ |
corpus/rtl-page/cold/notebook/synthetic.one created| Binary files /dev/null and b/corpus/rtl-page/cold/notebook/synthetic.one differ |
corpus/rtl-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | Fictitious attachment for native corpus. | |
| \ No newline at end of file |
corpus/rtl-page/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-BE5B6549", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/rtl-page/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="{585DA7EE-5A9A-49CE-9098-6F11595934F6}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T08:00:42.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="synthetic" ID="{17FA89D8-F415-0795-13C4-A65167861492}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-10-01T08:00:42.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{3BB37A5E-7DDE-4484-94A3-0EC09BBE088D}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-05T05:11:41.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> |
corpus/rtl-page/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": "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/rtl-page/cold/read/page-000.png created| Binary files /dev/null and b/corpus/rtl-page/cold/read/page-000.png differ |
corpus/rtl-page/cold/read/page-000.xml created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3BB37A5E-7DDE-4484-94A3-0EC09BBE088D}{1}{B0}" name="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:QuickStyleDef index="1" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="true" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="snow" authorInitials="S" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{5C7FD2E2-36AB-4732-9B51-821B6422EDEC}{10}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.513916015625" height="13.42771625518799"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{5C7FD2E2-36AB-4732-9B51-821B6422EDEC}{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>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 inkOriginX="0.01318359375" lastModifiedTime="2026-09-05T05:11:40.000Z" objectID="{7E92FE70-6E43-4981-86C9-2093A4A28038}{15}{B0}"><one:Position x="421.51025390625" y="84.75590515136718" z="5"/><one:Size width="60.009521484375" 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 inkOriginX="0.0494384765625" lastModifiedTime="2026-09-05T05:11:41.000Z" objectID="{7E92FE70-6E43-4981-86C9-2093A4A28038}{23}{B0}"><one:Position x="503.997802734375" y="92.23936462402343" z="6"/><one:Size width="30.01904296875" 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="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{0ACF0EF7-85FE-4D6F-81E7-0093C02E09A5}{10}{B0}"><one:Position x="180.0" y="122.400001525879" z="1"/><one:Size width="167.044921875" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T04:58:23.000Z" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{0ACF0EF7-85FE-4D6F-81E7-0093C02E09A5}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{B2FC7E18-A51C-4707-923A-CC9918928E18}{10}{B0}"><one:Position x="264.0003662109375" y="192.0" z="3"/><one:Size width="72.0" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:59.000Z" lastModifiedTime="2026-09-05T05:06:59.000Z" objectID="{B2FC7E18-A51C-4707-923A-CC9918928E18}{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-06T04:58:23.000Z" objectID="{37E66678-6364-44C1-B797-AB672C43412B}{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-06T04:58:23.000Z" objectID="{37E66678-6364-44C1-B797-AB672C43412B}{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-06T04:58:23.000Z" objectID="{9C230748-C48C-4C87-8D32-2ECC30C1755E}{10}{B0}"><one:Position x="-236.94873046875" y="312.0" z="4"/><one:Size width="253.4996337890625" height="21.94773101806641"/><one:OEChildren><one:OE creationTime="2026-09-06T04:58:23.000Z" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{EEDA5401-E363-40AD-8498-DB2E76FE747A}{48}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{EEDA5401-E363-40AD-8498-DB2E76FE747A}{49}{B0}"><one:Columns><one:Column index="0" width="96.0" isLocked="true"/><one:Column index="1" width="144.0" isLocked="true"/></one:Columns><one:Row objectID="{EEDA5401-E363-40AD-8498-DB2E76FE747A}{50}{B0}" lastModifiedTime="2026-09-06T04:58:23.000Z"><one:Cell lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{EEDA5401-E363-40AD-8498-DB2E76FE747A}{51}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-06T04:58:23.000Z" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{9C230748-C48C-4C87-8D32-2ECC30C1755E}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{EEDA5401-E363-40AD-8498-DB2E76FE747A}{58}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-06T04:58:23.000Z" lastModifiedTime="2026-09-06T04:58:23.000Z" objectID="{9C230748-C48C-4C87-8D32-2ECC30C1755E}{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:Outline author="Snowbound Test" authorInitials="ST" lastModifiedBy="Snowbound Test" lastModifiedByInitials="ST" lastModifiedTime="2025-12-12T08:00:01.000Z" objectID="{987B5B39-BFDF-071B-392A-E94DA0D93F86}{7}{B0}"><one:Position x="126.0" y="356.3999938964844" z="9"/><one:Size width="167.877685546875" height="13.42773056030273"/><one:OEChildren><one:OE RTL="false" creationTime="2025-12-12T08:00:01.000Z" lastModifiedTime="2025-12-12T08:00:01.000Z" objectID="{987B5B39-BFDF-071B-392A-E94DA0D93F86}{5}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Typed on a right-to-left page]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2025-12-12T08:00:02.000Z" objectID="{987B5B39-BFDF-071B-392A-E94DA0D93F86}{8}{B0}"><one:Position x="167.7381591796875" y="389.9905395507812" z="8"/><one:Size width="75.0333251953125" height="30.0472297668457"/><one:Data>APMBHQPIAVABYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR | |
| 27 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRE+Zx9 | |
| 28 | BQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXBWC/xca9f4uMc5SVKSpSIL/ | |
| 29 | AFTB/gCn3AFgAAUURgDAAAAJBAUURgAAAAASABEgzCikRHwBF0ik6a3tvqHwOgo/QCO0AoyWs9A7 | |
| 30 | gAMVRgBACj9AI0bCHMUqWCWg | |
| 31 | </one:Data></one:InkDrawing></one:Page> |
corpus/rtl-page/cold/read/payloads.json created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", | |
| 4 | "name": "fictitious-attachment.txt", | |
| 5 | "object": "{B2FC7E18-A51C-4707-923A-CC9918928E18}{11}{B0}", | |
| 6 | "kind": "InsertedFile", | |
| 7 | "page": "{3BB37A5E-7DDE-4484-94A3-0EC09BBE088D}{1}{B0}", | |
| 8 | "bytes": 43 | |
| 9 | } | |
| 10 | ] | |
| \ No newline at end of file |
corpus/rtl-page/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-rtl/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/rtl-page/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/rtl-page/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/rtl-page/cold/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 1612, | |
| 5 | "sha256": "3064cce91ed16ee1b2ff088d096fbd19492a6802a2f45a94447ccad490d8c452", | |
| 6 | "mtime_ns": 1790841446325464192 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "synthetic.one", | |
| 10 | "bytes": 45760, | |
| 11 | "sha256": "7ee4b534028071dde4ced7e24109ed381c96bb3198728eb27b5d7b59de2c7a5d", | |
| 12 | "mtime_ns": 1790841446324673647 | |
| 13 | } | |
| 14 | ] |
corpus/rtl-page/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
crates/canvas/Cargo.toml+4| ... | ... | @@ -46,6 +46,10 @@ wgpu.workspace = true |
| 46 | 46 | name = "render_page" |
| 47 | 47 | required-features = ["gpu"] |
| 48 | 48 | |
| 49 | [[example]] | |
| 50 | name = "reading_inventory" | |
| 51 | required-features = ["gpu"] | |
| 52 | ||
| 49 | 53 | [[example]] |
| 50 | 54 | name = "print_pdf" |
| 51 | 55 | required-features = ["pdf"] |
crates/canvas/examples/reading_inventory.rs created+382| ... | ... | @@ -0,0 +1,382 @@ |
| 1 | //! How many pages a reading view reflows into a phone's column, across the active pages of | |
| 2 | //! every distinct section under the given directories, as Markdown; with `--json FILE`, one | |
| 3 | //! line per page with its blocks in reading order. `--render SECTION INDEX PREFIX` draws page | |
| 4 | //! INDEX of SECTION whole (`PREFIX-page.png`) and reflowed (`PREFIX-column.png`). | |
| 5 | //! | |
| 6 | //! `cargo run --release -p canvas --features gpu --example reading_inventory -- [--column 230] corpus` | |
| 7 | ||
| 8 | use canvas::{ | |
| 9 | gpu::{Paper, Viewport, page::PageScene}, | |
| 10 | layout::TextEngine, | |
| 11 | reading::{self, Kept, Kind, Refusal, Verdict}, | |
| 12 | }; | |
| 13 | use draw::Renderer; | |
| 14 | use onestore::{Arena, Section, page::Page}; | |
| 15 | use std::{ | |
| 16 | collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, | |
| 17 | fmt::Write as _, | |
| 18 | hash::{Hash, Hasher}, | |
| 19 | io::Write as _, | |
| 20 | path::{Path, PathBuf}, | |
| 21 | time::Duration, | |
| 22 | }; | |
| 23 | ||
| 24 | /// Host points per page point at the iOS app's 100%: OneNote's 96 dpi enlarged 15%. | |
| 25 | const POINT: f32 = 96.0 / 72.0 * 1.15; | |
| 26 | ||
| 27 | fn sections(directory: &Path, out: &mut Vec<PathBuf>) { | |
| 28 | let Ok(entries) = std::fs::read_dir(directory) else { | |
| 29 | return; | |
| 30 | }; | |
| 31 | let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect(); | |
| 32 | entries.sort(); | |
| 33 | for path in entries { | |
| 34 | if path.is_dir() { | |
| 35 | sections(&path, out); | |
| 36 | } else if path.extension().is_some_and(|e| e == "one") { | |
| 37 | out.push(path); | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | fn label(verdict: &Verdict) -> String { | |
| 43 | match verdict { | |
| 44 | Verdict::Fits => "fits".into(), | |
| 45 | Verdict::Reflows => "reflows".into(), | |
| 46 | Verdict::Partial(kept) => format!( | |
| 47 | "partial: {}", | |
| 48 | kept.iter() | |
| 49 | .map(|k| kept_name(*k)) | |
| 50 | .collect::<Vec<_>>() | |
| 51 | .join(", ") | |
| 52 | ), | |
| 53 | Verdict::Refused(refusal) => format!( | |
| 54 | "refused: {}", | |
| 55 | match refusal { | |
| 56 | Refusal::RightToLeft => "right to left", | |
| 57 | Refusal::Drawn => "mostly ink", | |
| 58 | Refusal::Arranged => "mostly overlapping", | |
| 59 | } | |
| 60 | ), | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | fn kept_name(kept: Kept) -> &'static str { | |
| 65 | match kept { | |
| 66 | Kept::Asides => "asides", | |
| 67 | Kept::SideBySide => "side by side", | |
| 68 | Kept::Group => "overlap kept whole", | |
| 69 | Kept::Wide => "wider than the column", | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | fn kind_name(kind: Kind) -> &'static str { | |
| 74 | match kind { | |
| 75 | Kind::Text => "text", | |
| 76 | Kind::Picture => "picture", | |
| 77 | Kind::File => "file", | |
| 78 | Kind::Drawing => "drawing", | |
| 79 | Kind::Group => "group", | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | fn open(path: &Path) -> Option<Vec<(String, Page)>> { | |
| 84 | let image = std::fs::read(path).ok()?; | |
| 85 | let arena = Arena::default(); | |
| 86 | let mut section = Section::open(&arena, image).ok()?; | |
| 87 | let pages = section.pages().ok()?; | |
| 88 | Some( | |
| 89 | pages | |
| 90 | .into_iter() | |
| 91 | .filter_map(|(space, title, _)| section.page(space).ok().map(|page| (title, page))) | |
| 92 | .collect(), | |
| 93 | ) | |
| 94 | } | |
| 95 | ||
| 96 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 97 | let mut args: Vec<String> = std::env::args().skip(1).collect(); | |
| 98 | let mut column = 230.0; | |
| 99 | let mut json = None; | |
| 100 | if let Some(at) = args.iter().position(|a| a == "--column") { | |
| 101 | column = args[at + 1].parse()?; | |
| 102 | args.drain(at..at + 2); | |
| 103 | } | |
| 104 | if let Some(at) = args.iter().position(|a| a == "--json") { | |
| 105 | json = Some(std::fs::File::create(&args[at + 1])?); | |
| 106 | args.drain(at..at + 2); | |
| 107 | } | |
| 108 | let mut engine = TextEngine::default(); | |
| 109 | if args.first().is_some_and(|a| a == "--render") { | |
| 110 | let [_, section, index, prefix] = &args[..] else { | |
| 111 | return Err("--render SECTION INDEX PREFIX".into()); | |
| 112 | }; | |
| 113 | let pages = open(Path::new(section)).ok_or("section does not open")?; | |
| 114 | let (_, page) = pages | |
| 115 | .into_iter() | |
| 116 | .nth(index.parse()?) | |
| 117 | .ok_or("no such page")?; | |
| 118 | let read = reading::read(&page, column, &mut engine)?; | |
| 119 | let mut gpu = Gpu::new()?; | |
| 120 | gpu.whole(&page, &mut engine, &format!("{prefix}-page.png"))?; | |
| 121 | if let Some(reflowed) = &read.page { | |
| 122 | gpu.column( | |
| 123 | reflowed, | |
| 124 | column, | |
| 125 | &mut engine, | |
| 126 | &format!("{prefix}-column.png"), | |
| 127 | )?; | |
| 128 | } | |
| 129 | println!("{}", label(&read.verdict)); | |
| 130 | return Ok(()); | |
| 131 | } | |
| 132 | let mut seen = BTreeSet::new(); | |
| 133 | let mut distinct = BTreeSet::new(); | |
| 134 | let mut verdicts: BTreeMap<String, Vec<String>> = BTreeMap::new(); | |
| 135 | let (mut pages, mut failed) = (0, 0); | |
| 136 | for root in &args { | |
| 137 | let mut paths = Vec::new(); | |
| 138 | sections(Path::new(root), &mut paths); | |
| 139 | for path in paths { | |
| 140 | let Ok(bytes) = std::fs::read(&path) else { | |
| 141 | continue; | |
| 142 | }; | |
| 143 | let mut hasher = DefaultHasher::new(); | |
| 144 | bytes.hash(&mut hasher); | |
| 145 | if !seen.insert(hasher.finish()) { | |
| 146 | continue; | |
| 147 | } | |
| 148 | let Some(section) = open(&path) else { | |
| 149 | continue; | |
| 150 | }; | |
| 151 | for (index, (title, page)) in section.into_iter().enumerate() { | |
| 152 | // Fixtures repeat pages across their runs; each page counts once. | |
| 153 | let mut hasher = DefaultHasher::new(); | |
| 154 | serde_json::to_string(&page)?.hash(&mut hasher); | |
| 155 | if !distinct.insert(hasher.finish()) { | |
| 156 | continue; | |
| 157 | } | |
| 158 | pages += 1; | |
| 159 | let place = format!("{} #{index} — {title}", path.display()); | |
| 160 | let read = match reading::read(&page, column, &mut engine) { | |
| 161 | Ok(read) => read, | |
| 162 | Err(_) => { | |
| 163 | failed += 1; | |
| 164 | continue; | |
| 165 | } | |
| 166 | }; | |
| 167 | let verdict = label(&read.verdict); | |
| 168 | if let Some(json) = &mut json { | |
| 169 | let blocks: Vec<_> = read | |
| 170 | .blocks | |
| 171 | .iter() | |
| 172 | .map(|b| serde_json::json!({"kind": kind_name(b.kind), "members": b.members.len(), "bounds": b.bounds})) | |
| 173 | .collect(); | |
| 174 | let line = serde_json::json!({ | |
| 175 | "section": path.display().to_string(), "index": index, "title": title, | |
| 176 | "verdict": verdict, "offered": read.verdict.offered(), "blocks": blocks, | |
| 177 | }); | |
| 178 | writeln!(json, "{line}")?; | |
| 179 | } | |
| 180 | verdicts.entry(verdict).or_default().push(place); | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | let mut out = String::new(); | |
| 185 | writeln!(out, "# Reading view eligibility\n")?; | |
| 186 | writeln!( | |
| 187 | out, | |
| 188 | "{} distinct sections, {pages} distinct pages ({failed} failed to lay out), column {column} pt ({:.0} pt on screen at 100%).\n", | |
| 189 | seen.len(), | |
| 190 | column * POINT | |
| 191 | )?; | |
| 192 | writeln!( | |
| 193 | out, | |
| 194 | "| Verdict | Pages | Share | Examples |\n| --- | ---: | ---: | --- |" | |
| 195 | )?; | |
| 196 | let mut rows: Vec<_> = verdicts.iter().collect(); | |
| 197 | rows.sort_by_key(|row| std::cmp::Reverse(row.1.len())); | |
| 198 | for (verdict, places) in rows { | |
| 199 | let examples: Vec<String> = places.iter().take(3).map(|p| format!("`{p}`")).collect(); | |
| 200 | writeln!( | |
| 201 | out, | |
| 202 | "| {verdict} | {} | {:.1}% | {} |", | |
| 203 | places.len(), | |
| 204 | 100.0 * places.len() as f64 / pages.max(1) as f64, | |
| 205 | examples.join("<br>").replace('|', "\\|") | |
| 206 | )?; | |
| 207 | } | |
| 208 | print!("{out}"); | |
| 209 | Ok(()) | |
| 210 | } | |
| 211 | ||
| 212 | struct Gpu { | |
| 213 | renderer: Renderer, | |
| 214 | } | |
| 215 | ||
| 216 | impl Gpu { | |
| 217 | fn new() -> Result<Self, Box<dyn std::error::Error>> { | |
| 218 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 219 | let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; | |
| 220 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; | |
| 221 | Ok(Self { | |
| 222 | renderer: Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb), | |
| 223 | }) | |
| 224 | } | |
| 225 | ||
| 226 | /// The whole page, 804 pixels across: a phone's width at 2x. | |
| 227 | fn whole( | |
| 228 | &mut self, | |
| 229 | page: &Page, | |
| 230 | engine: &mut TextEngine, | |
| 231 | path: &str, | |
| 232 | ) -> Result<(), Box<dyn std::error::Error>> { | |
| 233 | let (scene, bounds) = scene(page, engine)?; | |
| 234 | let [x0, y0, x1, y1] = bounds; | |
| 235 | let scale = 780.0 / (x1 - x0); | |
| 236 | let height = (((y1 - y0) * scale) as u32 + 24).min(4000); | |
| 237 | self.draw( | |
| 238 | scene, | |
| 239 | Viewport { | |
| 240 | size: [804, height], | |
| 241 | scale, | |
| 242 | origin: [12.0 - x0 * scale, 12.0 - y0 * scale], | |
| 243 | }, | |
| 244 | path, | |
| 245 | ) | |
| 246 | } | |
| 247 | ||
| 248 | /// The reflowed page as the phone shows it at 100%, 34 points of tag room on its left. | |
| 249 | fn column( | |
| 250 | &mut self, | |
| 251 | page: &Page, | |
| 252 | column: f32, | |
| 253 | engine: &mut TextEngine, | |
| 254 | path: &str, | |
| 255 | ) -> Result<(), Box<dyn std::error::Error>> { | |
| 256 | let (scene, [x0, y0, _, y1]) = scene(page, engine)?; | |
| 257 | let scale = 2.0 * POINT; | |
| 258 | let _ = column; | |
| 259 | let height = (((y1 - y0) * scale) as u32 + 48).min(6000); | |
| 260 | self.draw( | |
| 261 | scene, | |
| 262 | Viewport { | |
| 263 | size: [804, height], | |
| 264 | scale, | |
| 265 | origin: [68.0 - x0 * scale, 24.0 - y0 * scale], | |
| 266 | }, | |
| 267 | path, | |
| 268 | ) | |
| 269 | } | |
| 270 | ||
| 271 | fn draw( | |
| 272 | &mut self, | |
| 273 | mut scene: PageScene, | |
| 274 | viewport: Viewport, | |
| 275 | path: &str, | |
| 276 | ) -> Result<(), Box<dyn std::error::Error>> { | |
| 277 | scene.settle(None, viewport.scale, Paper::WHITE); | |
| 278 | let mut primitives = Vec::new(); | |
| 279 | scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; | |
| 280 | let size = viewport.size; | |
| 281 | let device = self.renderer.device().clone(); | |
| 282 | let target = device.create_texture(&wgpu::TextureDescriptor { | |
| 283 | label: None, | |
| 284 | size: wgpu::Extent3d { | |
| 285 | width: size[0], | |
| 286 | height: size[1], | |
| 287 | depth_or_array_layers: 1, | |
| 288 | }, | |
| 289 | mip_level_count: 1, | |
| 290 | sample_count: 1, | |
| 291 | dimension: wgpu::TextureDimension::D2, | |
| 292 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 293 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, | |
| 294 | view_formats: &[], | |
| 295 | }); | |
| 296 | let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); | |
| 297 | let buffer = device.create_buffer(&wgpu::BufferDescriptor { | |
| 298 | label: None, | |
| 299 | size: u64::from(row_bytes) * u64::from(size[1]), | |
| 300 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, | |
| 301 | mapped_at_creation: false, | |
| 302 | }); | |
| 303 | self.renderer | |
| 304 | .draw( | |
| 305 | &target.create_view(&Default::default()), | |
| 306 | size, | |
| 307 | [1.0; 4], | |
| 308 | &[viewport.layer(&primitives)], | |
| 309 | ) | |
| 310 | .map_err(|e| format!("{e:?}"))?; | |
| 311 | let mut encoder = device.create_command_encoder(&Default::default()); | |
| 312 | encoder.copy_texture_to_buffer( | |
| 313 | target.as_image_copy(), | |
| 314 | wgpu::TexelCopyBufferInfo { | |
| 315 | buffer: &buffer, | |
| 316 | layout: wgpu::TexelCopyBufferLayout { | |
| 317 | offset: 0, | |
| 318 | bytes_per_row: Some(row_bytes), | |
| 319 | rows_per_image: Some(size[1]), | |
| 320 | }, | |
| 321 | }, | |
| 322 | wgpu::Extent3d { | |
| 323 | width: size[0], | |
| 324 | height: size[1], | |
| 325 | depth_or_array_layers: 1, | |
| 326 | }, | |
| 327 | ); | |
| 328 | self.renderer.queue().submit([encoder.finish()]); | |
| 329 | let (sender, receiver) = std::sync::mpsc::channel(); | |
| 330 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { | |
| 331 | let _ = sender.send(result); | |
| 332 | }); | |
| 333 | device.poll(wgpu::PollType::Wait { | |
| 334 | submission_index: None, | |
| 335 | timeout: Some(Duration::from_secs(10)), | |
| 336 | })?; | |
| 337 | receiver.recv_timeout(Duration::from_secs(10))??; | |
| 338 | let mapped = buffer.get_mapped_range(..)?; | |
| 339 | let pixels: Vec<u8> = mapped | |
| 340 | .chunks_exact(row_bytes as usize) | |
| 341 | .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) | |
| 342 | .collect(); | |
| 343 | let mut encoder = png::Encoder::new(std::fs::File::create(path)?, size[0], size[1]); | |
| 344 | encoder.set_color(png::ColorType::Rgba); | |
| 345 | encoder.set_depth(png::BitDepth::Eight); | |
| 346 | encoder.write_header()?.write_image_data(&pixels)?; | |
| 347 | Ok(()) | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 351 | /// The page's scene, pictures decoded, and its content's bounds with the title. | |
| 352 | fn scene( | |
| 353 | page: &Page, | |
| 354 | engine: &mut TextEngine, | |
| 355 | ) -> Result<(PageScene, [f32; 4]), Box<dyn std::error::Error>> { | |
| 356 | let editor = canvas::editor::CanvasEditor::from_page(page.clone(), engine)?; | |
| 357 | let mut bounds = [ | |
| 358 | f32::INFINITY, | |
| 359 | f32::INFINITY, | |
| 360 | f32::NEG_INFINITY, | |
| 361 | f32::NEG_INFINITY, | |
| 362 | ]; | |
| 363 | for outline in editor.outlines() { | |
| 364 | let b = outline.bounds(); | |
| 365 | bounds = [ | |
| 366 | bounds[0].min(b.x0 as f32), | |
| 367 | bounds[1].min(b.y0 as f32), | |
| 368 | bounds[2].max(b.x1 as f32), | |
| 369 | bounds[3].max(b.y1 as f32), | |
| 370 | ]; | |
| 371 | } | |
| 372 | for block in reading::read(page, f32::INFINITY, engine)?.blocks { | |
| 373 | let b = block.bounds; | |
| 374 | bounds = [ | |
| 375 | bounds[0].min(b[0]), | |
| 376 | bounds[1].min(b[1]), | |
| 377 | bounds[2].max(b[2]), | |
| 378 | bounds[3].max(b[3]), | |
| 379 | ]; | |
| 380 | } | |
| 381 | Ok((PageScene::new(page.clone(), engine)?, bounds)) | |
| 382 | } |
crates/canvas/examples/unsupported_inventory.rs+46-3| ... | ... | @@ -544,7 +544,38 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent |
| 544 | 544 | probe(&mut editor, engine, inventory, place); |
| 545 | 545 | } |
| 546 | 546 | |
| 547 | fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) { | |
| 547 | /// The passwords the roots' `manifest.json` files record for their protected sections. | |
| 548 | fn passwords(directory: &Path, out: &mut Vec<String>) { | |
| 549 | let Ok(entries) = std::fs::read_dir(directory) else { | |
| 550 | return; | |
| 551 | }; | |
| 552 | for path in entries.flatten().map(|entry| entry.path()) { | |
| 553 | if path.is_dir() { | |
| 554 | passwords(&path, out); | |
| 555 | } else if path.file_name().is_some_and(|name| name == "manifest.json") | |
| 556 | && let Ok(bytes) = std::fs::read(&path) | |
| 557 | && let Ok(manifest) = serde_json::from_slice::<serde_json::Value>(&bytes) | |
| 558 | { | |
| 559 | let named = manifest["native"] | |
| 560 | .as_object() | |
| 561 | .into_iter() | |
| 562 | .flat_map(|map| map.values()); | |
| 563 | out.extend( | |
| 564 | std::iter::once(&manifest["password"]) | |
| 565 | .chain(named) | |
| 566 | .filter_map(|password| password.as_str().map(str::to_owned)), | |
| 567 | ); | |
| 568 | } | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | fn section( | |
| 573 | engine: &mut TextEngine, | |
| 574 | path: &Path, | |
| 575 | root: &Path, | |
| 576 | passwords: &[String], | |
| 577 | inventory: &mut Inventory, | |
| 578 | ) { | |
| 548 | 579 | let Ok(image) = std::fs::read(path) else { |
| 549 | 580 | return; |
| 550 | 581 | }; |
| ... | ... | @@ -554,7 +585,15 @@ fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut In |
| 554 | 585 | .display() |
| 555 | 586 | .to_string(); |
| 556 | 587 | let arena = Arena::default(); |
| 557 | let mut section = match Section::open(&arena, image) { | |
| 588 | // A protected section opens under the password its fixture records. | |
| 589 | let key = passwords | |
| 590 | .iter() | |
| 591 | .find_map(|password| onestore::protected::Key::open(&image, password).ok()); | |
| 592 | let opened = match &key { | |
| 593 | Some(key) => Section::unlock(&arena, image, key).map_err(onestore::Error::from), | |
| 594 | None => Section::open(&arena, image), | |
| 595 | }; | |
| 596 | let mut section = match opened { | |
| 558 | 597 | Ok(section) => section, |
| 559 | 598 | Err(error) => { |
| 560 | 599 | inventory.add( |
| ... | ... | @@ -612,6 +651,10 @@ fn main() { |
| 612 | 651 | let mut inventory = Inventory::default(); |
| 613 | 652 | let mut seen = BTreeSet::new(); |
| 614 | 653 | let mut duplicates = 0; |
| 654 | let mut known = Vec::new(); | |
| 655 | for root in &roots { | |
| 656 | passwords(root, &mut known); | |
| 657 | } | |
| 615 | 658 | for root in &roots { |
| 616 | 659 | let mut paths = Vec::new(); |
| 617 | 660 | sections(root, &mut paths); |
| ... | ... | @@ -625,7 +668,7 @@ fn main() { |
| 625 | 668 | duplicates += 1; |
| 626 | 669 | continue; |
| 627 | 670 | } |
| 628 | section(&mut engine, &path, root, &mut inventory); | |
| 671 | section(&mut engine, &path, root, &known, &mut inventory); | |
| 629 | 672 | } |
| 630 | 673 | } |
| 631 | 674 | println!("# Unsupported content inventory\n"); |
crates/canvas/src/conflict.rs+14-30| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | //! Conflict pages and page versions as OneNote shows them. |
| 2 | 2 | |
| 3 | use crate::document::descendants; | |
| 3 | 4 | use onestore::{ |
| 4 | 5 | ExGuid, |
| 5 | 6 | page::{Page, PageObject, PageParagraph, ParagraphContent}, |
| ... | ... | @@ -21,9 +22,19 @@ pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page { |
| 21 | 22 | /// banded as OneNote shows it; all of it without one. |
| 22 | 23 | pub fn changes(version: Page, older: Option<&Page>) -> Page { |
| 23 | 24 | let texts = |page: &Page| { |
| 24 | let mut texts = Vec::new(); | |
| 25 | each_text(page, &mut |id, text| texts.push((id, text.to_owned()))); | |
| 26 | texts | |
| 25 | page.objects | |
| 26 | .iter() | |
| 27 | .flat_map(|object| match object { | |
| 28 | PageObject::Outline(outline) => std::slice::from_ref(outline), | |
| 29 | PageObject::Title(title) => title.outlines.as_slice(), | |
| 30 | _ => &[], | |
| 31 | }) | |
| 32 | .flat_map(|outline| descendants(&outline.paragraphs, None)) | |
| 33 | .filter_map(|(_, _, node)| match &node.content { | |
| 34 | ParagraphContent::Text(text) => Some((text.id, text.text.text().to_owned())), | |
| 35 | _ => None, | |
| 36 | }) | |
| 37 | .collect::<Vec<_>>() | |
| 27 | 38 | }; |
| 28 | 39 | let before = older.map(texts).unwrap_or_default(); |
| 29 | 40 | let changed: Vec<ExGuid> = texts(&version) |
| ... | ... | @@ -34,33 +45,6 @@ pub fn changes(version: Page, older: Option<&Page>) -> Page { |
| 34 | 45 | banded(version, &changed, CHANGED) |
| 35 | 46 | } |
| 36 | 47 | |
| 37 | fn each_text(page: &Page, f: &mut impl FnMut(ExGuid, &str)) { | |
| 38 | fn walk(paragraphs: &[PageParagraph], f: &mut impl FnMut(ExGuid, &str)) { | |
| 39 | for paragraph in paragraphs { | |
| 40 | match &paragraph.content { | |
| 41 | ParagraphContent::Table(table) => { | |
| 42 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | |
| 43 | walk(&cell.paragraphs, f); | |
| 44 | } | |
| 45 | } | |
| 46 | ParagraphContent::Text(text) => f(text.id, text.text.text()), | |
| 47 | _ => {} | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | for object in &page.objects { | |
| 52 | match object { | |
| 53 | PageObject::Outline(outline) => walk(&outline.paragraphs, f), | |
| 54 | PageObject::Title(title) => { | |
| 55 | for outline in &title.outlines { | |
| 56 | walk(&outline.paragraphs, f); | |
| 57 | } | |
| 58 | } | |
| 59 | _ => {} | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | 48 | fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page { |
| 65 | 49 | fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) { |
| 66 | 50 | for paragraph in paragraphs { |
crates/canvas/src/date.rs+1-1| ... | ... | @@ -32,7 +32,7 @@ impl PageDate { |
| 32 | 32 | &self.layout |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | pub fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> { | |
| 35 | pub(crate) fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> { | |
| 36 | 36 | let swapped = self |
| 37 | 37 | .source |
| 38 | 38 | .paragraphs |
crates/canvas/src/document.rs+5-5| ... | ... | @@ -26,11 +26,11 @@ pub struct TextDocument { |
| 26 | 26 | #[derive(Clone, Debug, PartialEq)] |
| 27 | 27 | pub(crate) struct DocumentEdit { |
| 28 | 28 | /// Cell identity, or None for the document root. |
| 29 | pub container: Option<ExGuid>, | |
| 30 | pub range: Range<usize>, | |
| 31 | pub replacement: Vec<PageParagraph>, | |
| 29 | pub(crate) container: Option<ExGuid>, | |
| 30 | pub(crate) range: Range<usize>, | |
| 31 | pub(crate) replacement: Vec<PageParagraph>, | |
| 32 | 32 | /// Width patches address surviving tables outside the replacement subtree. |
| 33 | pub columns: BTreeMap<ExGuid, Vec<f32>>, | |
| 33 | pub(crate) columns: BTreeMap<ExGuid, Vec<f32>>, | |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> { |
| ... | ... | @@ -757,7 +757,7 @@ impl TextDocument { |
| 757 | 757 | Err(EditError::InvalidRange) |
| 758 | 758 | } |
| 759 | 759 | |
| 760 | pub fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> { | |
| 760 | pub(crate) fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> { | |
| 761 | 761 | if range.start > range.end { |
| 762 | 762 | return Err(EditError::InvalidRange); |
| 763 | 763 | } |
crates/canvas/src/editor.rs+129-119| ... | ... | @@ -100,10 +100,7 @@ pub struct Awaited { |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | 102 | /// A new picture of encoded `bytes`, `size` points large. |
| 103 | pub(crate) fn picture( | |
| 104 | bytes: Vec<u8>, | |
| 105 | size: [f32; 2], | |
| 106 | ) -> Result<onestore::page::Image, EditError> { | |
| 103 | pub fn picture(bytes: Vec<u8>, size: [f32; 2]) -> Result<onestore::page::Image, EditError> { | |
| 107 | 104 | Ok(onestore::page::Image { |
| 108 | 105 | id: onestore::page::text::new_id()?, |
| 109 | 106 | layout: onestore::document::Layout { |
| ... | ... | @@ -187,6 +184,7 @@ struct PageHeader { |
| 187 | 184 | identity: Option<[u8; 16]>, |
| 188 | 185 | created: Option<u64>, |
| 189 | 186 | margin_origin: [f32; 2], |
| 187 | rtl: bool, | |
| 190 | 188 | color: Option<u32>, |
| 191 | 189 | rule_lines: Option<onestore::page::RuleLines>, |
| 192 | 190 | areas: Vec<page::TitleArea>, |
| ... | ... | @@ -378,7 +376,7 @@ impl TextOutline { |
| 378 | 376 | } |
| 379 | 377 | |
| 380 | 378 | pub fn origin(&self) -> [f32; 2] { |
| 381 | [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)] | |
| 379 | crate::origin(&self.layout) | |
| 382 | 380 | } |
| 383 | 381 | |
| 384 | 382 | pub fn wrap_width(&self) -> f32 { |
| ... | ... | @@ -509,7 +507,7 @@ impl TextOutline { |
| 509 | 507 | } |
| 510 | 508 | |
| 511 | 509 | /// All the outline's shown text. |
| 512 | pub fn whole(&self) -> Selection { | |
| 510 | pub(crate) fn whole(&self) -> Selection { | |
| 513 | 511 | let paragraph = self.source_index(self.shaped.paragraphs.len() - 1); |
| 514 | 512 | let last = self.document.paragraph(paragraph).unwrap(); |
| 515 | 513 | [ |
| ... | ... | @@ -587,7 +585,11 @@ impl TextOutline { |
| 587 | 585 | |
| 588 | 586 | /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each |
| 589 | 587 | /// line's left, right and baseline. |
| 590 | pub fn underlines(&self, index: usize, range: Range<u32>) -> Result<Vec<[f32; 3]>, EditError> { | |
| 588 | pub(crate) fn underlines( | |
| 589 | &self, | |
| 590 | index: usize, | |
| 591 | range: Range<u32>, | |
| 592 | ) -> Result<Vec<[f32; 3]>, EditError> { | |
| 591 | 593 | let paragraph = self.paragraph_layout(index)?; |
| 592 | 594 | let selection = ParagraphSelection::new( |
| 593 | 595 | paragraph.cursor(range.start, Affinity::Downstream)?, |
| ... | ... | @@ -935,39 +937,15 @@ impl CanvasEditor { |
| 935 | 937 | width: f32, |
| 936 | 938 | ) -> Result<Self, EditorError> { |
| 937 | 939 | let outline = TextOutline::new(engine, document, width, [0.0; 2])?; |
| 938 | let (outlines, active) = if outline.is_empty() { | |
| 939 | ( | |
| 940 | Vec::new(), | |
| 941 | Focus::Caret { | |
| 942 | outline: Box::new(outline), | |
| 943 | index: 0, | |
| 944 | }, | |
| 945 | ) | |
| 946 | } else { | |
| 947 | (vec![outline], Focus::Outline(0)) | |
| 948 | }; | |
| 949 | Ok(Self { | |
| 950 | outlines, | |
| 951 | definitions: BTreeMap::new(), | |
| 952 | objects: Vec::new(), | |
| 953 | date: None, | |
| 954 | header: PageHeader::default(), | |
| 955 | active, | |
| 956 | undo: Vec::new(), | |
| 957 | redo: Vec::new(), | |
| 958 | composition: None, | |
| 959 | preferred_x: None, | |
| 960 | pending: None, | |
| 961 | ops: Ok(Vec::new()), | |
| 962 | stored: None, | |
| 963 | whole: None, | |
| 964 | typing: None, | |
| 965 | recording: None, | |
| 966 | ink_drag: None, | |
| 967 | provisional: BTreeMap::new(), | |
| 968 | default_font: DefaultFont::default(), | |
| 969 | styles: BTreeMap::new(), | |
| 970 | }) | |
| 940 | let empty = outline.is_empty(); | |
| 941 | let mut editor = Self::from_text_outlines(vec![outline], BTreeMap::new(), None)?; | |
| 942 | if empty { | |
| 943 | editor.active = Focus::Caret { | |
| 944 | outline: Box::new(editor.outlines.pop().unwrap()), | |
| 945 | index: 0, | |
| 946 | }; | |
| 947 | } | |
| 948 | Ok(editor) | |
| 971 | 949 | } |
| 972 | 950 | |
| 973 | 951 | pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> { |
| ... | ... | @@ -1014,6 +992,7 @@ impl CanvasEditor { |
| 1014 | 992 | identity: page.identity, |
| 1015 | 993 | created: page.created, |
| 1016 | 994 | margin_origin: page.margin_origin, |
| 995 | rtl: page.rtl, | |
| 1017 | 996 | color: page.color, |
| 1018 | 997 | rule_lines: page.rule_lines, |
| 1019 | 998 | areas, |
| ... | ... | @@ -1317,6 +1296,7 @@ impl CanvasEditor { |
| 1317 | 1296 | .map(PageDate::timestamp) |
| 1318 | 1297 | .or(self.header.created), |
| 1319 | 1298 | margin_origin: self.header.margin_origin, |
| 1299 | rtl: self.header.rtl, | |
| 1320 | 1300 | color: self.header.color, |
| 1321 | 1301 | rule_lines: self.header.rule_lines, |
| 1322 | 1302 | objects, |
| ... | ... | @@ -1516,7 +1496,8 @@ impl CanvasEditor { |
| 1516 | 1496 | .collect() |
| 1517 | 1497 | } |
| 1518 | 1498 | |
| 1519 | pub fn from_outlines( | |
| 1499 | #[cfg(test)] | |
| 1500 | pub(crate) fn from_outlines( | |
| 1520 | 1501 | engine: &mut TextEngine, |
| 1521 | 1502 | outlines: Vec<Outline>, |
| 1522 | 1503 | definitions: BTreeMap<ExGuid, Definition>, |
| ... | ... | @@ -1631,14 +1612,15 @@ impl CanvasEditor { |
| 1631 | 1612 | return false; |
| 1632 | 1613 | } |
| 1633 | 1614 | self.finish_composition(); |
| 1634 | let mut entries = Vec::new(); | |
| 1615 | let depth = self.undo.len(); | |
| 1635 | 1616 | if paper { |
| 1636 | entries.push(self.paper(color, rule_lines)); | |
| 1617 | let entry = self.paper(color, rule_lines); | |
| 1618 | self.undo.push(entry); | |
| 1637 | 1619 | } |
| 1638 | 1620 | if let Some(art) = art { |
| 1639 | 1621 | for index in backgrounds.into_iter().rev() { |
| 1640 | 1622 | let image = self.remove_picture(index); |
| 1641 | entries.push(History::Picture { | |
| 1623 | self.undo.push(History::Picture { | |
| 1642 | 1624 | index, |
| 1643 | 1625 | image: Some(Box::new(image)), |
| 1644 | 1626 | }); |
| ... | ... | @@ -1646,19 +1628,13 @@ impl CanvasEditor { |
| 1646 | 1628 | // Art lies under everything else on the page. |
| 1647 | 1629 | for image in art.into_iter().rev() { |
| 1648 | 1630 | self.add_picture(0, image); |
| 1649 | entries.push(History::Picture { | |
| 1631 | self.undo.push(History::Picture { | |
| 1650 | 1632 | index: 0, |
| 1651 | 1633 | image: None, |
| 1652 | 1634 | }); |
| 1653 | 1635 | } |
| 1654 | 1636 | } |
| 1655 | self.undo.push(match entries.len() { | |
| 1656 | 1 => entries.pop().unwrap(), | |
| 1657 | _ => History::Group { | |
| 1658 | entries, | |
| 1659 | page: false, | |
| 1660 | }, | |
| 1661 | }); | |
| 1637 | self.group(depth, false); | |
| 1662 | 1638 | self.redo.clear(); |
| 1663 | 1639 | true |
| 1664 | 1640 | } |
| ... | ... | @@ -1748,7 +1724,6 @@ impl CanvasEditor { |
| 1748 | 1724 | } |
| 1749 | 1725 | } |
| 1750 | 1726 | |
| 1751 | /// Provisional input geometry outside the document's outline collection. | |
| 1752 | 1727 | /// The caret a run of typing left while typing there still joins the run: the word |
| 1753 | 1728 | /// before it is still being written. |
| 1754 | 1729 | pub fn typing(&self) -> Option<(ExGuid, TextPosition)> { |
| ... | ... | @@ -1759,6 +1734,7 @@ impl CanvasEditor { |
| 1759 | 1734 | .then_some((outline, at)) |
| 1760 | 1735 | } |
| 1761 | 1736 | |
| 1737 | /// Provisional input geometry outside the document's outline collection. | |
| 1762 | 1738 | pub fn caret_outline(&self) -> Option<&TextOutline> { |
| 1763 | 1739 | match &self.active { |
| 1764 | 1740 | Focus::Caret { outline, .. } => Some(outline), |
| ... | ... | @@ -2020,9 +1996,8 @@ impl CanvasEditor { |
| 2020 | 1996 | return Some(([x0, y0], [x1 - x0, y1 - y0])); |
| 2021 | 1997 | } |
| 2022 | 1998 | if let Some(image) = self.image(id) { |
| 2023 | let layout = &image.layout; | |
| 2024 | 1999 | return Some(( |
| 2025 | [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], | |
| 2000 | crate::origin(&image.layout), | |
| 2026 | 2001 | crate::outline::image_size(image)?, |
| 2027 | 2002 | )); |
| 2028 | 2003 | } |
| ... | ... | @@ -2034,7 +2009,7 @@ impl CanvasEditor { |
| 2034 | 2009 | } |
| 2035 | 2010 | |
| 2036 | 2011 | /// Whether the picture sits in an outline's flow, where only its size can change. |
| 2037 | pub fn image_in_outline(&self, id: ExGuid) -> bool { | |
| 2012 | pub(crate) fn image_in_outline(&self, id: ExGuid) -> bool { | |
| 2038 | 2013 | self.outline_picture(id).is_some() |
| 2039 | 2014 | } |
| 2040 | 2015 | |
| ... | ... | @@ -2052,7 +2027,7 @@ impl CanvasEditor { |
| 2052 | 2027 | } |
| 2053 | 2028 | |
| 2054 | 2029 | /// The address picture `id` links to, in an outline or on the page. |
| 2055 | pub fn picture_link(&self, id: ExGuid) -> Option<&str> { | |
| 2030 | pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> { | |
| 2056 | 2031 | let image = match self.image(id) { |
| 2057 | 2032 | Some(image) => image, |
| 2058 | 2033 | None => match &self.outline_picture(id)?.3.content { |
| ... | ... | @@ -2064,7 +2039,7 @@ impl CanvasEditor { |
| 2064 | 2039 | } |
| 2065 | 2040 | |
| 2066 | 2041 | /// The file a paragraph holds or the page shows, which the host opens and saves. |
| 2067 | pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { | |
| 2042 | pub(crate) fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { | |
| 2068 | 2043 | if let Some((file, _)) = self.page_file(id) { |
| 2069 | 2044 | return Some(file); |
| 2070 | 2045 | } |
| ... | ... | @@ -2083,15 +2058,14 @@ impl CanvasEditor { |
| 2083 | 2058 | } |
| 2084 | 2059 | |
| 2085 | 2060 | /// Where a file draws with its name, in page coordinates. |
| 2086 | pub fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> { | |
| 2061 | pub(crate) fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> { | |
| 2087 | 2062 | if let Some((_, rect)) = self.page_file(id) { |
| 2088 | 2063 | return Some(rect); |
| 2089 | 2064 | } |
| 2090 | 2065 | let (outline, ..) = self.outline_picture(id)?; |
| 2091 | 2066 | let outline = self.outlines.iter().find(|item| item.id == outline)?; |
| 2092 | let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); | |
| 2093 | let [x, y] = outline.origin(); | |
| 2094 | Some([x + x0, y + y0, x + x1, y + y1]) | |
| 2067 | let bounds = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); | |
| 2068 | Some(crate::translated(bounds, outline.origin())) | |
| 2095 | 2069 | } |
| 2096 | 2070 | |
| 2097 | 2071 | /// Replaces a picture paragraph through the outline's text history. |
| ... | ... | @@ -2231,7 +2205,7 @@ impl CanvasEditor { |
| 2231 | 2205 | /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the |
| 2232 | 2206 | /// text outline before it in page order, or to the start of the one after. False when there |
| 2233 | 2207 | /// is none. |
| 2234 | pub fn step_from_image( | |
| 2208 | pub(crate) fn step_from_image( | |
| 2235 | 2209 | &mut self, |
| 2236 | 2210 | engine: &mut TextEngine, |
| 2237 | 2211 | id: ExGuid, |
| ... | ... | @@ -2394,15 +2368,7 @@ impl CanvasEditor { |
| 2394 | 2368 | break; |
| 2395 | 2369 | } |
| 2396 | 2370 | } |
| 2397 | let entries = self.undo.split_off(depth); | |
| 2398 | if entries.len() > 1 { | |
| 2399 | self.undo.push(History::Group { | |
| 2400 | entries, | |
| 2401 | page: false, | |
| 2402 | }); | |
| 2403 | } else { | |
| 2404 | self.undo.extend(entries); | |
| 2405 | } | |
| 2371 | self.group(depth, false); | |
| 2406 | 2372 | result.map(|()| awaited) |
| 2407 | 2373 | } |
| 2408 | 2374 | |
| ... | ... | @@ -2414,7 +2380,11 @@ impl CanvasEditor { |
| 2414 | 2380 | at: Awaited, |
| 2415 | 2381 | image: onestore::page::Image, |
| 2416 | 2382 | ) -> Result<bool, EditorError> { |
| 2417 | let Some(outline) = self.outlines.iter().find(|outline| outline.id == at.outline) else { | |
| 2383 | let Some(outline) = self | |
| 2384 | .outlines | |
| 2385 | .iter() | |
| 2386 | .find(|outline| outline.id == at.outline) | |
| 2387 | else { | |
| 2418 | 2388 | return Ok(false); |
| 2419 | 2389 | }; |
| 2420 | 2390 | let Some((container, index, node)) = descendants(outline.document.nodes(), None) |
| ... | ... | @@ -2605,6 +2575,12 @@ impl CanvasEditor { |
| 2605 | 2575 | self.header.margin_origin |
| 2606 | 2576 | } |
| 2607 | 2577 | |
| 2578 | /// Whether the page runs right to left, which OneNote 2010 opens scrolled to its right | |
| 2579 | /// end. | |
| 2580 | pub fn rtl(&self) -> bool { | |
| 2581 | self.header.rtl | |
| 2582 | } | |
| 2583 | ||
| 2608 | 2584 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { |
| 2609 | 2585 | let outline = self.active_outline(); |
| 2610 | 2586 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) |
| ... | ... | @@ -2760,7 +2736,7 @@ impl CanvasEditor { |
| 2760 | 2736 | } |
| 2761 | 2737 | |
| 2762 | 2738 | /// Selects outline `id` as an object, as a click on its handle does. |
| 2763 | pub fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> { | |
| 2739 | pub(crate) fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> { | |
| 2764 | 2740 | self.focus_outline(id)?; |
| 2765 | 2741 | let whole = self.active_outline().whole(); |
| 2766 | 2742 | self.select(whole)?; |
| ... | ... | @@ -2793,20 +2769,38 @@ impl CanvasEditor { |
| 2793 | 2769 | Ok(()) |
| 2794 | 2770 | } |
| 2795 | 2771 | |
| 2796 | /// Runs `edit` as one undo step. | |
| 2772 | /// Makes the history entries past `depth` one undo step, `page` when they edited OneNote's | |
| 2773 | /// page selection; false when there are none. Groups among them are flattened into it. | |
| 2774 | fn group(&mut self, depth: usize, mut page: bool) -> bool { | |
| 2775 | let mut entries = Vec::new(); | |
| 2776 | for entry in self.undo.split_off(depth) { | |
| 2777 | match entry { | |
| 2778 | History::Group { | |
| 2779 | entries: inner, | |
| 2780 | page: selected, | |
| 2781 | } => { | |
| 2782 | page |= selected; | |
| 2783 | entries.extend(inner); | |
| 2784 | } | |
| 2785 | entry => entries.push(entry), | |
| 2786 | } | |
| 2787 | } | |
| 2788 | match entries.len() { | |
| 2789 | 0 => return false, | |
| 2790 | 1 if !page => self.undo.extend(entries), | |
| 2791 | _ => self.undo.push(History::Group { entries, page }), | |
| 2792 | } | |
| 2793 | true | |
| 2794 | } | |
| 2795 | ||
| 2796 | /// Runs `edit`, an edit of OneNote's page selection, as one undo step. | |
| 2797 | 2797 | fn grouped<T>( |
| 2798 | 2798 | &mut self, |
| 2799 | 2799 | edit: impl FnOnce(&mut Self) -> Result<T, EditorError>, |
| 2800 | 2800 | ) -> Result<T, EditorError> { |
| 2801 | 2801 | let depth = self.undo.len(); |
| 2802 | 2802 | let result = edit(self); |
| 2803 | let entries = self.undo.split_off(depth); | |
| 2804 | if !entries.is_empty() { | |
| 2805 | self.undo.push(History::Group { | |
| 2806 | entries, | |
| 2807 | page: true, | |
| 2808 | }); | |
| 2809 | } | |
| 2803 | self.group(depth, true); | |
| 2810 | 2804 | result |
| 2811 | 2805 | } |
| 2812 | 2806 | |
| ... | ... | @@ -2828,13 +2822,7 @@ impl CanvasEditor { |
| 2828 | 2822 | } |
| 2829 | 2823 | let depth = self.undo.len(); |
| 2830 | 2824 | let result = self.delete(engine, false).and_then(|_| edit(self, engine)); |
| 2831 | let entries = self.undo.split_off(depth); | |
| 2832 | if !entries.is_empty() { | |
| 2833 | self.undo.push(History::Group { | |
| 2834 | entries, | |
| 2835 | page: false, | |
| 2836 | }); | |
| 2837 | } | |
| 2825 | self.group(depth, false); | |
| 2838 | 2826 | result.map(Some) |
| 2839 | 2827 | } |
| 2840 | 2828 | |
| ... | ... | @@ -2858,27 +2846,8 @@ impl CanvasEditor { |
| 2858 | 2846 | }); |
| 2859 | 2847 | let depth = self.undo.len() - usize::from(joins); |
| 2860 | 2848 | let result = edit(self); |
| 2861 | let (mut run, mut page) = (Vec::new(), false); | |
| 2862 | for entry in self.undo.split_off(depth) { | |
| 2863 | match entry { | |
| 2864 | History::Group { | |
| 2865 | entries, | |
| 2866 | page: selected, | |
| 2867 | } => { | |
| 2868 | page |= selected; | |
| 2869 | if run.is_empty() { | |
| 2870 | run = entries; | |
| 2871 | } else { | |
| 2872 | run.extend(entries); | |
| 2873 | } | |
| 2874 | } | |
| 2875 | entry => run.push(entry), | |
| 2876 | } | |
| 2877 | } | |
| 2878 | match run.len() { | |
| 2879 | 0 => return result, | |
| 2880 | 1 if !page => self.undo.extend(run), | |
| 2881 | _ => self.undo.push(History::Group { entries: run, page }), | |
| 2849 | if !self.group(depth, false) { | |
| 2850 | return result; | |
| 2882 | 2851 | } |
| 2883 | 2852 | if result.is_ok() { |
| 2884 | 2853 | let caret = self.selection().positions[1]; |
| ... | ... | @@ -2957,7 +2926,7 @@ impl CanvasEditor { |
| 2957 | 2926 | }) |
| 2958 | 2927 | } |
| 2959 | 2928 | |
| 2960 | pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { | |
| 2929 | pub(crate) fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { | |
| 2961 | 2930 | let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; |
| 2962 | 2931 | if extend { |
| 2963 | 2932 | selection.positions[0] = self.selection().positions[0]; |
| ... | ... | @@ -3448,12 +3417,12 @@ impl CanvasEditor { |
| 3448 | 3417 | Ok(()) |
| 3449 | 3418 | } |
| 3450 | 3419 | |
| 3451 | pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 3420 | pub(crate) fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 3452 | 3421 | self.active_outline() |
| 3453 | 3422 | .range_rects(self.active_outline().selection) |
| 3454 | 3423 | } |
| 3455 | 3424 | |
| 3456 | pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 3425 | pub(crate) fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 3457 | 3426 | match &self.composition { |
| 3458 | 3427 | Some(composition) => self.active_outline().range_rects(Selection { |
| 3459 | 3428 | positions: [composition.range.start, composition.range.end], |
| ... | ... | @@ -3506,7 +3475,11 @@ impl CanvasEditor { |
| 3506 | 3475 | |
| 3507 | 3476 | /// Replaces the selection with `text` in the formatting of the text it replaces, as a |
| 3508 | 3477 | /// spelling correction does. |
| 3509 | pub fn correct(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { | |
| 3478 | pub(crate) fn correct( | |
| 3479 | &mut self, | |
| 3480 | engine: &mut TextEngine, | |
| 3481 | text: &str, | |
| 3482 | ) -> Result<(), EditorError> { | |
| 3510 | 3483 | let [anchor, focus] = self.selection().positions; |
| 3511 | 3484 | let end = anchor.max(focus); |
| 3512 | 3485 | let format = self |
| ... | ... | @@ -3752,7 +3725,7 @@ impl CanvasEditor { |
| 3752 | 3725 | |
| 3753 | 3726 | /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the |
| 3754 | 3727 | /// sibling above or below with its children. Does nothing past the first or last sibling. |
| 3755 | pub fn move_paragraphs( | |
| 3728 | pub(crate) fn move_paragraphs( | |
| 3756 | 3729 | &mut self, |
| 3757 | 3730 | engine: &mut TextEngine, |
| 3758 | 3731 | up: bool, |
| ... | ... | @@ -3994,7 +3967,7 @@ impl CanvasEditor { |
| 3994 | 3967 | |
| 3995 | 3968 | /// Takes up `parked`, the editor this page had when it was last left, history and all, |
| 3996 | 3969 | /// in place of this one: what storage changed since reaches it as `refresh` would. |
| 3997 | pub fn resume( | |
| 3970 | pub(crate) fn resume( | |
| 3998 | 3971 | &mut self, |
| 3999 | 3972 | mut parked: CanvasEditor, |
| 4000 | 3973 | engine: &mut TextEngine, |
| ... | ... | @@ -4928,9 +4901,6 @@ impl CanvasEditor { |
| 4928 | 4901 | } |
| 4929 | 4902 | } |
| 4930 | 4903 | |
| 4931 | /// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same | |
| 4932 | /// paragraph, past what changed in its text when it lies after it; at the start of the | |
| 4933 | /// paragraph now at its place when that one is gone. | |
| 4934 | 4904 | /// Whether `content` is picture or file `id`. |
| 4935 | 4905 | fn holds(content: &ParagraphContent, id: ExGuid) -> bool { |
| 4936 | 4906 | match content { |
| ... | ... | @@ -4940,6 +4910,9 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool { |
| 4940 | 4910 | } |
| 4941 | 4911 | } |
| 4942 | 4912 | |
| 4913 | /// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same | |
| 4914 | /// paragraph, past what changed in its text when it lies after it; at the start of the | |
| 4915 | /// paragraph now at its place when that one is gone. | |
| 4943 | 4916 | fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { |
| 4944 | 4917 | let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { |
| 4945 | 4918 | let paragraph = new |
| ... | ... | @@ -6447,6 +6420,7 @@ mod tests { |
| 6447 | 6420 | identity: None, |
| 6448 | 6421 | created: None, |
| 6449 | 6422 | margin_origin: [36.0, 14.4], |
| 6423 | rtl: false, | |
| 6450 | 6424 | color: None, |
| 6451 | 6425 | rule_lines: None, |
| 6452 | 6426 | definitions: BTreeMap::new(), |
| ... | ... | @@ -6558,6 +6532,7 @@ mod tests { |
| 6558 | 6532 | identity: None, |
| 6559 | 6533 | created: None, |
| 6560 | 6534 | margin_origin: [36.0, 14.4], |
| 6535 | rtl: false, | |
| 6561 | 6536 | color: None, |
| 6562 | 6537 | rule_lines: None, |
| 6563 | 6538 | definitions: BTreeMap::new(), |
| ... | ... | @@ -6656,6 +6631,7 @@ mod tests { |
| 6656 | 6631 | identity: None, |
| 6657 | 6632 | created: None, |
| 6658 | 6633 | margin_origin: [36.0, 14.4], |
| 6634 | rtl: false, | |
| 6659 | 6635 | color: None, |
| 6660 | 6636 | rule_lines: None, |
| 6661 | 6637 | definitions: BTreeMap::new(), |
| ... | ... | @@ -6720,6 +6696,7 @@ mod tests { |
| 6720 | 6696 | identity: None, |
| 6721 | 6697 | created: None, |
| 6722 | 6698 | margin_origin: [36.0, 14.4], |
| 6699 | rtl: false, | |
| 6723 | 6700 | color: None, |
| 6724 | 6701 | rule_lines: None, |
| 6725 | 6702 | definitions: BTreeMap::new(), |
| ... | ... | @@ -6840,6 +6817,7 @@ mod tests { |
| 6840 | 6817 | identity: None, |
| 6841 | 6818 | created: None, |
| 6842 | 6819 | margin_origin: [36.0, 14.4], |
| 6820 | rtl: false, | |
| 6843 | 6821 | color: None, |
| 6844 | 6822 | rule_lines: None, |
| 6845 | 6823 | definitions: BTreeMap::new(), |
| ... | ... | @@ -6959,6 +6937,7 @@ mod tests { |
| 6959 | 6937 | identity: None, |
| 6960 | 6938 | created: None, |
| 6961 | 6939 | margin_origin: [36.0, 14.4], |
| 6940 | rtl: false, | |
| 6962 | 6941 | color: None, |
| 6963 | 6942 | rule_lines: None, |
| 6964 | 6943 | definitions: BTreeMap::new(), |
| ... | ... | @@ -7060,6 +7039,7 @@ mod tests { |
| 7060 | 7039 | identity: None, |
| 7061 | 7040 | created: None, |
| 7062 | 7041 | margin_origin: [36.0, 14.4], |
| 7042 | rtl: false, | |
| 7063 | 7043 | color: None, |
| 7064 | 7044 | rule_lines: None, |
| 7065 | 7045 | definitions: BTreeMap::new(), |
| ... | ... | @@ -10623,4 +10603,34 @@ mod tests { |
| 10623 | 10603 | [[(0, 0), (0, 5)]; 6] |
| 10624 | 10604 | ); |
| 10625 | 10605 | } |
| 10606 | ||
| 10607 | #[test] | |
| 10608 | fn pasting_over_the_page_selection_is_one_step_that_undo_selects_again() { | |
| 10609 | let mut engine = TextEngine::default(); | |
| 10610 | let lines = ["One", "Two"].map(|line| Paragraph::new(line.into(), Format::default())); | |
| 10611 | let mut editor = CanvasEditor::new( | |
| 10612 | &mut engine, | |
| 10613 | TextDocument::new(lines.to_vec()).unwrap(), | |
| 10614 | 400.0, | |
| 10615 | ) | |
| 10616 | .unwrap(); | |
| 10617 | editor.select_page().unwrap(); | |
| 10618 | editor | |
| 10619 | .paste_pieces( | |
| 10620 | &mut engine, | |
| 10621 | vec![ | |
| 10622 | Piece::Text("Pasted".into()), | |
| 10623 | Piece::Picture(vec![0; 8], [10.0; 2]), | |
| 10624 | ], | |
| 10625 | 0x409, | |
| 10626 | ) | |
| 10627 | .unwrap(); | |
| 10628 | assert_eq!(editor.history_depth(), [1, 0]); | |
| 10629 | let _ = editor.take_ops(); | |
| 10630 | let mut remote = editor.page().unwrap(); | |
| 10631 | remote.color = Some(0x00ff_ffff); | |
| 10632 | editor.refresh(remote, &mut engine).unwrap(); | |
| 10633 | assert!(editor.undo(&mut engine).unwrap()); | |
| 10634 | assert_eq!(editor.whole(), Some(Whole::Page)); | |
| 10635 | } | |
| 10626 | 10636 | } |
crates/canvas/src/editor/equation.rs+2-10| ... | ... | @@ -186,7 +186,7 @@ impl CanvasEditor { |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | 188 | /// Whether the caret is in an equation, which Linear and Professional act on. |
| 189 | pub fn in_equation(&self) -> bool { | |
| 189 | pub(crate) fn in_equation(&self) -> bool { | |
| 190 | 190 | self.caret_zone().is_some() |
| 191 | 191 | } |
| 192 | 192 | |
| ... | ... | @@ -270,15 +270,7 @@ impl CanvasEditor { |
| 270 | 270 | } |
| 271 | 271 | Ok(()) |
| 272 | 272 | })(); |
| 273 | let entries = self.undo.split_off(depth); | |
| 274 | match entries.len() { | |
| 275 | 0 => {} | |
| 276 | 1 => self.undo.extend(entries), | |
| 277 | _ => self.undo.push(History::Group { | |
| 278 | entries, | |
| 279 | page: false, | |
| 280 | }), | |
| 281 | } | |
| 273 | self.group(depth, false); | |
| 282 | 274 | result |
| 283 | 275 | } |
| 284 | 276 |
crates/canvas/src/editor/format.rs+63-89| ... | ... | @@ -208,8 +208,8 @@ impl DefaultFont { |
| 208 | 208 | subscript: Some(false), |
| 209 | 209 | font: Some(self.face.clone()), |
| 210 | 210 | font_size: Some(self.size), |
| 211 | color: Some(self.color.unwrap_or(0xff00_0000)), | |
| 212 | highlight: Some(0xff00_0000), | |
| 211 | color: Some(self.color.unwrap_or(AUTOMATIC)), | |
| 212 | highlight: Some(AUTOMATIC), | |
| 213 | 213 | space_before: Some(0.0), |
| 214 | 214 | space_after: Some(0.0), |
| 215 | 215 | line_spacing: Some(0.0), |
| ... | ... | @@ -293,7 +293,7 @@ impl NoteTag { |
| 293 | 293 | } |
| 294 | 294 | |
| 295 | 295 | /// The definition OneNote stores for the tag at place `action_type` in the list. |
| 296 | pub fn definition(&self, action_type: u16) -> Definition { | |
| 296 | pub(crate) fn definition(&self, action_type: u16) -> Definition { | |
| 297 | 297 | Definition { |
| 298 | 298 | kind: Kind::TagDefinition { |
| 299 | 299 | label: Some(self.label.clone()), |
| ... | ... | @@ -453,18 +453,18 @@ fn covered( |
| 453 | 453 | }) |
| 454 | 454 | } |
| 455 | 455 | |
| 456 | pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) { | |
| 456 | pub(super) fn leaves_mut(nodes: &mut [PageParagraph]) -> Vec<&mut PageParagraph> { | |
| 457 | let mut leaves = Vec::new(); | |
| 457 | 458 | for node in nodes { |
| 458 | match &mut node.content { | |
| 459 | ParagraphContent::Text(_) => change(node), | |
| 460 | ParagraphContent::Table(table) => { | |
| 461 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { | |
| 462 | leaves_mut(&mut cell.paragraphs, change); | |
| 463 | } | |
| 459 | if node.text().is_some() { | |
| 460 | leaves.push(node); | |
| 461 | } else if let ParagraphContent::Table(table) = &mut node.content { | |
| 462 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { | |
| 463 | leaves.extend(leaves_mut(&mut cell.paragraphs)); | |
| 464 | 464 | } |
| 465 | _ => {} | |
| 466 | 465 | } |
| 467 | 466 | } |
| 467 | leaves | |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | 470 | /// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty. |
| ... | ... | @@ -753,6 +753,7 @@ impl CanvasEditor { |
| 753 | 753 | | Formatting::Tag(..) |
| 754 | 754 | | Formatting::RemoveTags |
| 755 | 755 | | Formatting::Check |
| 756 | | Formatting::Style(_) | |
| 756 | 757 | if title => |
| 757 | 758 | { |
| 758 | 759 | return Ok(()); |
| ... | ... | @@ -763,7 +764,7 @@ impl CanvasEditor { |
| 763 | 764 | } |
| 764 | 765 | Formatting::Align(alignment) => { |
| 765 | 766 | let bases = bases(&replacement)?; |
| 766 | leaves_mut(&mut replacement, &mut |node| { | |
| 767 | for node in leaves_mut(&mut replacement) { | |
| 767 | 768 | if ranges.remove(&node.id).is_some() { |
| 768 | 769 | let value = match alignment { |
| 769 | 770 | Alignment::Left => bases[&node.id].alignment.map(|_| 0), |
| ... | ... | @@ -775,7 +776,7 @@ impl CanvasEditor { |
| 775 | 776 | format.alignment = value; |
| 776 | 777 | }); |
| 777 | 778 | } |
| 778 | }); | |
| 779 | } | |
| 779 | 780 | } |
| 780 | 781 | Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => { |
| 781 | 782 | let style = match command { |
| ... | ... | @@ -821,7 +822,7 @@ impl CanvasEditor { |
| 821 | 822 | self.definitions.insert(id, definition); |
| 822 | 823 | lists.insert(node, id); |
| 823 | 824 | } |
| 824 | leaves_mut(&mut replacement, &mut |node| { | |
| 825 | for node in leaves_mut(&mut replacement) { | |
| 825 | 826 | if ranges.remove(&node.id).is_some() { |
| 826 | 827 | if remove { |
| 827 | 828 | node.lists.clear(); |
| ... | ... | @@ -829,50 +830,40 @@ impl CanvasEditor { |
| 829 | 830 | node.lists = vec![*id]; |
| 830 | 831 | } |
| 831 | 832 | } |
| 832 | }); | |
| 833 | } | |
| 833 | 834 | } |
| 834 | 835 | Formatting::Tag(tag, action_type) => { |
| 835 | 836 | let definition = tag.definition(*action_type); |
| 836 | let existing = self | |
| 837 | .definitions | |
| 838 | .iter() | |
| 839 | .find(|(_, other)| other.kind == definition.kind); | |
| 840 | let id = match existing { | |
| 841 | Some((id, _)) => *id, | |
| 842 | None => { | |
| 843 | let id = new_id()?; | |
| 844 | self.definitions.insert(id, definition.clone()); | |
| 845 | id | |
| 846 | } | |
| 847 | }; | |
| 837 | let id = self.define_tag(&definition)?; | |
| 848 | 838 | let has = |node: &PageParagraph| { |
| 849 | 839 | tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) |
| 850 | 840 | }; |
| 851 | 841 | let remove = covered(&replacement, ends).all(|(node, _)| has(node)); |
| 852 | 842 | let created = time32(); |
| 853 | leaves_mut(&mut replacement, &mut |node| { | |
| 843 | for node in leaves_mut(&mut replacement) { | |
| 854 | 844 | if ranges.remove(&node.id).is_none() || !remove && has(node) { |
| 855 | return; | |
| 845 | continue; | |
| 856 | 846 | } |
| 857 | 847 | let ParagraphContent::Text(text) = &mut node.content else { |
| 858 | 848 | unreachable!() |
| 859 | 849 | }; |
| 860 | 850 | let added = (!remove).then_some((id, tag.shape, created)); |
| 861 | 851 | self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); |
| 862 | }); | |
| 852 | } | |
| 863 | 853 | } |
| 864 | Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| { | |
| 865 | if ranges.remove(&node.id).is_some() { | |
| 866 | node.tags.clear(); | |
| 867 | node.text_mut().unwrap().tags.clear(); | |
| 854 | Formatting::RemoveTags => { | |
| 855 | for node in leaves_mut(&mut replacement) { | |
| 856 | if ranges.remove(&node.id).is_some() { | |
| 857 | node.tags.clear(); | |
| 858 | node.text_mut().unwrap().tags.clear(); | |
| 859 | } | |
| 868 | 860 | } |
| 869 | }), | |
| 870 | Formatting::Check => self.check(&mut replacement, ranges, ends), | |
| 871 | Formatting::Style(_) if title => return Ok(()), | |
| 861 | } | |
| 862 | Formatting::Check => self.check(&mut replacement, &ranges), | |
| 872 | 863 | Formatting::Style(definition) => { |
| 873 | 864 | let olds = bases(&replacement)?; |
| 874 | 865 | let style = self.define_style(definition)?; |
| 875 | leaves_mut(&mut replacement, &mut |node| { | |
| 866 | for node in leaves_mut(&mut replacement) { | |
| 876 | 867 | if ranges.remove(&node.id).is_some() { |
| 877 | 868 | node.style = Some(style); |
| 878 | 869 | let old = &olds[&node.id]; |
| ... | ... | @@ -899,7 +890,7 @@ impl CanvasEditor { |
| 899 | 890 | }; |
| 900 | 891 | }); |
| 901 | 892 | } |
| 902 | }); | |
| 893 | } | |
| 903 | 894 | } |
| 904 | 895 | Formatting::Toggle(_) |
| 905 | 896 | | Formatting::Font(_) |
| ... | ... | @@ -971,7 +962,7 @@ impl CanvasEditor { |
| 971 | 962 | self.pending = Some((id, caret, format)); |
| 972 | 963 | return Ok(()); |
| 973 | 964 | } |
| 974 | leaves_mut(&mut replacement, &mut |node| { | |
| 965 | for node in leaves_mut(&mut replacement) { | |
| 975 | 966 | if let Some(range) = ranges.remove(&node.id) { |
| 976 | 967 | let base = &bases[&node.id]; |
| 977 | 968 | let text = &mut node.text_mut().unwrap().text; |
| ... | ... | @@ -984,7 +975,7 @@ impl CanvasEditor { |
| 984 | 975 | }); |
| 985 | 976 | } |
| 986 | 977 | } |
| 987 | }); | |
| 978 | } | |
| 988 | 979 | } |
| 989 | 980 | } |
| 990 | 981 | if self.active_outline().document.container(container)?[range.clone()] == replacement[..] { |
| ... | ... | @@ -1018,6 +1009,20 @@ impl CanvasEditor { |
| 1018 | 1009 | Ok(id) |
| 1019 | 1010 | } |
| 1020 | 1011 | |
| 1012 | /// The page's definition of the tag `definition` describes, made where it has none. | |
| 1013 | fn define_tag(&mut self, definition: &Definition) -> Result<ExGuid, EditError> { | |
| 1014 | if let Some((id, _)) = self | |
| 1015 | .definitions | |
| 1016 | .iter() | |
| 1017 | .find(|(_, other)| other.kind == definition.kind) | |
| 1018 | { | |
| 1019 | return Ok(*id); | |
| 1020 | } | |
| 1021 | let id = new_id()?; | |
| 1022 | self.definitions.insert(id, definition.clone()); | |
| 1023 | Ok(id) | |
| 1024 | } | |
| 1025 | ||
| 1021 | 1026 | /// A paragraph style named `name`: the page's in its theme's formatting, else the page's |
| 1022 | 1027 | /// first of that name, else the theme's made on the page; Normal falls back to the |
| 1023 | 1028 | /// Default font's. None where nothing names it. |
| ... | ... | @@ -1100,7 +1105,7 @@ impl CanvasEditor { |
| 1100 | 1105 | let ranges = covered(&replacement, ends) |
| 1101 | 1106 | .map(|(node, range)| (node.id, range)) |
| 1102 | 1107 | .collect(); |
| 1103 | self.check(&mut replacement, ranges, ends); | |
| 1108 | self.check(&mut replacement, &ranges); | |
| 1104 | 1109 | let selection = outline.selection; |
| 1105 | 1110 | self.commit( |
| 1106 | 1111 | engine, |
| ... | ... | @@ -1176,19 +1181,7 @@ impl CanvasEditor { |
| 1176 | 1181 | match command { |
| 1177 | 1182 | Formatting::Tag(tag, action_type) => { |
| 1178 | 1183 | let definition = tag.definition(*action_type); |
| 1179 | let existing = self | |
| 1180 | .definitions | |
| 1181 | .iter() | |
| 1182 | .find(|(_, other)| other.kind == definition.kind) | |
| 1183 | .map(|(id, _)| *id); | |
| 1184 | let defined = match existing { | |
| 1185 | Some(id) => id, | |
| 1186 | None => { | |
| 1187 | let id = new_id()?; | |
| 1188 | self.definitions.insert(id, definition.clone()); | |
| 1189 | id | |
| 1190 | } | |
| 1191 | }; | |
| 1184 | let defined = self.define_tag(&definition)?; | |
| 1192 | 1185 | let has = tags |
| 1193 | 1186 | .iter() |
| 1194 | 1187 | .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); |
| ... | ... | @@ -1284,7 +1277,8 @@ impl CanvasEditor { |
| 1284 | 1277 | ); |
| 1285 | 1278 | } |
| 1286 | 1279 | |
| 1287 | /// Checks the check boxes among `tags`, or clears them once all are checked. | |
| 1280 | /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps | |
| 1281 | /// a cleared box's completion time as zero. | |
| 1288 | 1282 | fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { |
| 1289 | 1283 | let checkable = |tag: &Tag| { |
| 1290 | 1284 | matches!( |
| ... | ... | @@ -1307,39 +1301,19 @@ impl CanvasEditor { |
| 1307 | 1301 | } |
| 1308 | 1302 | |
| 1309 | 1303 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are |
| 1310 | /// checked; OneNote keeps a cleared box's completion time as zero. | |
| 1311 | fn check( | |
| 1312 | &self, | |
| 1313 | replacement: &mut [PageParagraph], | |
| 1314 | mut covered: BTreeMap<ExGuid, Range<usize>>, | |
| 1315 | ends: Ends, | |
| 1316 | ) { | |
| 1317 | let checkable = |tag: &Tag| { | |
| 1318 | matches!( | |
| 1319 | self.tag_kind(tag), | |
| 1320 | Some(Kind::TagDefinition { shape: Some(shape), .. }) | |
| 1321 | if crate::outline::checkable(*shape) | |
| 1322 | ) | |
| 1323 | }; | |
| 1324 | let checked = self::covered(replacement, ends) | |
| 1325 | .flat_map(|(node, _)| tags(node)) | |
| 1326 | .filter(|tag| checkable(tag)) | |
| 1327 | .all(|tag| tag.status & 1 != 0); | |
| 1328 | let completed = if checked { Some(0) } else { time32() }; | |
| 1329 | leaves_mut(replacement, &mut |node| { | |
| 1330 | if covered.remove(&node.id).is_none() { | |
| 1331 | return; | |
| 1332 | } | |
| 1333 | let ParagraphContent::Text(text) = &mut node.content else { | |
| 1334 | unreachable!() | |
| 1335 | }; | |
| 1336 | for tag in node.tags.iter_mut().chain(&mut text.tags) { | |
| 1337 | if checkable(tag) && (tag.status & 1 != 0) == checked { | |
| 1338 | tag.status ^= 1; | |
| 1339 | tag.completed = completed; | |
| 1340 | } | |
| 1341 | } | |
| 1342 | }); | |
| 1304 | /// checked. | |
| 1305 | fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap<ExGuid, Range<usize>>) { | |
| 1306 | let tags = leaves_mut(replacement) | |
| 1307 | .into_iter() | |
| 1308 | .filter(|node| covered.contains_key(&node.id)) | |
| 1309 | .flat_map(|node| { | |
| 1310 | let ParagraphContent::Text(text) = &mut node.content else { | |
| 1311 | unreachable!() | |
| 1312 | }; | |
| 1313 | node.tags.iter_mut().chain(&mut text.tags) | |
| 1314 | }) | |
| 1315 | .collect(); | |
| 1316 | self.toggle_checks(tags); | |
| 1343 | 1317 | } |
| 1344 | 1318 | } |
| 1345 | 1319 |
crates/canvas/src/editor/ink.rs+30-48| ... | ... | @@ -35,7 +35,7 @@ impl Pen { |
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | pub const fn highlighter(color: u32) -> Self { | |
| 38 | pub(crate) const fn highlighter(color: u32) -> Self { | |
| 39 | 39 | Self { |
| 40 | 40 | width: 70.0, |
| 41 | 41 | color: Some(color), |
| ... | ... | @@ -130,25 +130,8 @@ impl CanvasEditor { |
| 130 | 130 | let page::Content::Ink(ink) = &self.objects[index] else { |
| 131 | 131 | continue; |
| 132 | 132 | }; |
| 133 | let offset = page::ink_offset(ink); | |
| 134 | let touched = |stroke: &InkStroke| { | |
| 135 | let reach = reach + stroke.width.max(stroke.height) / 2.0; | |
| 136 | let mut points = stroke | |
| 137 | .points | |
| 138 | .iter() | |
| 139 | .map(|p| [p[0] + offset[0], p[1] + offset[1]]); | |
| 140 | let Some(mut previous) = points.next() else { | |
| 141 | return false; | |
| 142 | }; | |
| 143 | if segments_distance([previous, previous], [from, to]) <= reach { | |
| 144 | return true; | |
| 145 | } | |
| 146 | points.any(|point| { | |
| 147 | let near = segments_distance([previous, point], [from, to]) <= reach; | |
| 148 | previous = point; | |
| 149 | near | |
| 150 | }) | |
| 151 | }; | |
| 133 | let offset = crate::origin(&ink.layout); | |
| 134 | let touched = |stroke: &InkStroke| stroke_near(stroke, offset, [from, to], reach); | |
| 152 | 135 | let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink |
| 153 | 136 | .strokes |
| 154 | 137 | .iter() |
| ... | ... | @@ -216,13 +199,13 @@ impl CanvasEditor { |
| 216 | 199 | |
| 217 | 200 | /// The page's drawings, on top first, with most of their points inside `lasso`, a |
| 218 | 201 | /// closed path in page points. |
| 219 | pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> { | |
| 202 | pub(crate) fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> { | |
| 220 | 203 | self.objects |
| 221 | 204 | .iter() |
| 222 | 205 | .rev() |
| 223 | 206 | .filter_map(|object| match object { |
| 224 | 207 | page::Content::Ink(ink) => { |
| 225 | let offset = page::ink_offset(ink); | |
| 208 | let offset = crate::origin(&ink.layout); | |
| 226 | 209 | let points = points(ink); |
| 227 | 210 | let inside = points |
| 228 | 211 | .iter() |
| ... | ... | @@ -236,28 +219,11 @@ impl CanvasEditor { |
| 236 | 219 | } |
| 237 | 220 | |
| 238 | 221 | /// The drawing on top whose strokes pass within `reach` of `point`. |
| 239 | pub fn ink_at(&self, point: [f32; 2], reach: f32) -> Option<ExGuid> { | |
| 222 | pub(crate) fn ink_at(&self, point: [f32; 2], reach: f32) -> Option<ExGuid> { | |
| 240 | 223 | self.objects.iter().rev().find_map(|object| match object { |
| 241 | 224 | page::Content::Ink(ink) => { |
| 242 | let offset = page::ink_offset(ink); | |
| 243 | let near = |stroke: &InkStroke| { | |
| 244 | let reach = reach + stroke.width.max(stroke.height) / 2.0; | |
| 245 | let mut points = stroke | |
| 246 | .points | |
| 247 | .iter() | |
| 248 | .map(|p| [p[0] + offset[0], p[1] + offset[1]]); | |
| 249 | let first = points.next(); | |
| 250 | first.is_some_and(|first| { | |
| 251 | let mut previous = first; | |
| 252 | segments_distance([first, first], [point, point]) <= reach | |
| 253 | || points.any(|p| { | |
| 254 | let near = | |
| 255 | segments_distance([previous, p], [point, point]) <= reach; | |
| 256 | previous = p; | |
| 257 | near | |
| 258 | }) | |
| 259 | }) | |
| 260 | }; | |
| 225 | let offset = crate::origin(&ink.layout); | |
| 226 | let near = |stroke: &InkStroke| stroke_near(stroke, offset, [point, point], reach); | |
| 261 | 227 | (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near))) |
| 262 | 228 | .then_some(ink.id) |
| 263 | 229 | } |
| ... | ... | @@ -271,11 +237,8 @@ impl CanvasEditor { |
| 271 | 237 | self.objects |
| 272 | 238 | .iter() |
| 273 | 239 | .filter_map(|object| match object { |
| 274 | page::Content::Ink(ink) if ids.contains(&ink.id) => { | |
| 275 | let [dx, dy] = self.ink_drag_offset(ink.id); | |
| 276 | page::ink_bounds(ink) | |
| 277 | .map(|[x0, y0, x1, y1]| [x0 + dx, y0 + dy, x1 + dx, y1 + dy]) | |
| 278 | } | |
| 240 | page::Content::Ink(ink) if ids.contains(&ink.id) => page::ink_bounds(ink) | |
| 241 | .map(|bounds| crate::translated(bounds, self.ink_drag_offset(ink.id))), | |
| 279 | 242 | _ => None, |
| 280 | 243 | }) |
| 281 | 244 | .reduce(|a, b| { |
| ... | ... | @@ -290,7 +253,7 @@ impl CanvasEditor { |
| 290 | 253 | |
| 291 | 254 | /// Shows drawings dragged `delta` from where they lie, without storing it, until the |
| 292 | 255 | /// drag ends with `move_ink` or `None`. |
| 293 | pub fn drag_ink(&mut self, drag: Option<(Vec<ExGuid>, [f32; 2])>) { | |
| 256 | pub(crate) fn drag_ink(&mut self, drag: Option<(Vec<ExGuid>, [f32; 2])>) { | |
| 294 | 257 | self.ink_drag = drag; |
| 295 | 258 | } |
| 296 | 259 | |
| ... | ... | @@ -427,6 +390,25 @@ impl CanvasEditor { |
| 427 | 390 | } |
| 428 | 391 | } |
| 429 | 392 | |
| 393 | /// Whether `stroke`, of a drawing at `offset`, passes within `reach` of segment `path`, its pen's | |
| 394 | /// width included. | |
| 395 | fn stroke_near(stroke: &InkStroke, offset: [f32; 2], path: [[f32; 2]; 2], reach: f32) -> bool { | |
| 396 | let reach = reach + stroke.width.max(stroke.height) / 2.0; | |
| 397 | let mut points = stroke | |
| 398 | .points | |
| 399 | .iter() | |
| 400 | .map(|p| [p[0] + offset[0], p[1] + offset[1]]); | |
| 401 | let Some(mut previous) = points.next() else { | |
| 402 | return false; | |
| 403 | }; | |
| 404 | segments_distance([previous, previous], path) <= reach | |
| 405 | || points.any(|point| { | |
| 406 | let near = segments_distance([previous, point], path) <= reach; | |
| 407 | previous = point; | |
| 408 | near | |
| 409 | }) | |
| 410 | } | |
| 411 | ||
| 430 | 412 | fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool { |
| 431 | 413 | ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched)) |
| 432 | 414 | } |
crates/canvas/src/editor/link.rs+15-20| ... | ... | @@ -3,18 +3,18 @@ |
| 3 | 3 | //! typed and ended by a space or Enter becomes a link of its own text, typing after a label |
| 4 | 4 | //! is plain text, and Remove Link leaves the label as plain text. |
| 5 | 5 | |
| 6 | use super::format::{leaves_mut, restyle, selected}; | |
| 6 | use super::format::restyle; | |
| 7 | 7 | use super::*; |
| 8 | 8 | |
| 9 | 9 | /// A link in a paragraph, in UTF-16 offsets of its stored text. |
| 10 | 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | 11 | pub struct Link { |
| 12 | pub paragraph: usize, | |
| 12 | pub(crate) paragraph: usize, | |
| 13 | 13 | /// The hidden field code naming the address, before the label; none where the label is |
| 14 | 14 | /// its own address. |
| 15 | pub code: Option<Range<u32>>, | |
| 15 | pub(crate) code: Option<Range<u32>>, | |
| 16 | 16 | pub label: Range<u32>, |
| 17 | pub target: String, | |
| 17 | pub(crate) target: String, | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | impl Link { |
| ... | ... | @@ -193,7 +193,7 @@ impl CanvasEditor { |
| 193 | 193 | |
| 194 | 194 | /// The address of the link drawn under document point `point` of outline `id`, which a |
| 195 | 195 | /// click opens as OneNote's does. |
| 196 | pub fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> { | |
| 196 | pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> { | |
| 197 | 197 | let outline = self.visible_outlines().find(|outline| outline.id == id)?; |
| 198 | 198 | let index = outline.paragraph_at(x, y).ok()?; |
| 199 | 199 | let paragraph = &outline.shaped.paragraphs[index]; |
| ... | ... | @@ -414,7 +414,7 @@ impl CanvasEditor { |
| 414 | 414 | } |
| 415 | 415 | |
| 416 | 416 | /// Select Link: the label of the link at the caret. |
| 417 | pub fn select_link(&mut self) -> Result<bool, EditError> { | |
| 417 | pub(crate) fn select_link(&mut self) -> Result<bool, EditError> { | |
| 418 | 418 | let [anchor, focus] = self.active_outline().selection.positions; |
| 419 | 419 | let Some(link) = self.link_at(anchor.min(focus)) else { |
| 420 | 420 | return Ok(false); |
| ... | ... | @@ -491,25 +491,20 @@ impl CanvasEditor { |
| 491 | 491 | text: Paragraph, |
| 492 | 492 | selection: Selection, |
| 493 | 493 | ) -> Result<(), EditorError> { |
| 494 | let document = &self.active_outline().document; | |
| 495 | let at = TextPosition { | |
| 496 | paragraph, | |
| 497 | offset: 0, | |
| 498 | }; | |
| 499 | let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?; | |
| 500 | let mut replacement = document.container(container)?[range.clone()].to_vec(); | |
| 501 | leaves_mut(&mut replacement, &mut |node| { | |
| 502 | if node.id == id { | |
| 503 | node.text_mut().unwrap().text = text.clone(); | |
| 504 | } | |
| 505 | }); | |
| 494 | let (container, index, node) = self | |
| 495 | .active_outline() | |
| 496 | .document | |
| 497 | .leaf(paragraph) | |
| 498 | .ok_or(EditError::InvalidRange)?; | |
| 499 | let mut node = node.clone(); | |
| 500 | node.text_mut().unwrap().text = text; | |
| 506 | 501 | self.commit( |
| 507 | 502 | engine, |
| 508 | 503 | DocumentEdit { |
| 509 | 504 | columns: BTreeMap::new(), |
| 510 | 505 | container, |
| 511 | range, | |
| 512 | replacement, | |
| 506 | range: index..index + 1, | |
| 507 | replacement: vec![node], | |
| 513 | 508 | }, |
| 514 | 509 | selection, |
| 515 | 510 | ) |
crates/canvas/src/editor/ops.rs+12-19| ... | ... | @@ -285,7 +285,6 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { |
| 285 | 285 | Ok(ops) |
| 286 | 286 | } |
| 287 | 287 | |
| 288 | /// A page picture's stored position, size and description. | |
| 289 | 288 | /// The op giving `target` `tags`, with the definitions they name. |
| 290 | 289 | pub(super) fn tags( |
| 291 | 290 | target: ExGuid, |
| ... | ... | @@ -306,6 +305,7 @@ pub(super) fn tags( |
| 306 | 305 | }] |
| 307 | 306 | } |
| 308 | 307 | |
| 308 | /// A page picture's stored position, size and description. | |
| 309 | 309 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { |
| 310 | 310 | vec![PageOp::Picture { |
| 311 | 311 | picture: image.id, |
| ... | ... | @@ -404,25 +404,17 @@ impl CanvasEditor { |
| 404 | 404 | return layout_ops(outline.id, &old, &moved(&outline.layout)); |
| 405 | 405 | } |
| 406 | 406 | for object in &self.objects { |
| 407 | match object { | |
| 408 | page::Content::Image(image) if image.id == placement.id => return picture(image), | |
| 409 | page::Content::Ink(ink) if ink.id == placement.id => { | |
| 410 | return layout_ops(ink.id, &old, &moved(&ink.layout)); | |
| 411 | } | |
| 412 | page::Content::File { source, .. } if source.id == placement.id => { | |
| 413 | return layout_ops(source.id, &old, &moved(&source.layout)); | |
| 414 | } | |
| 415 | page::Content::Outline { source, .. } if source.id == placement.id => { | |
| 416 | return layout_ops(source.id, &old, &moved(&source.layout)); | |
| 407 | if let Some(image) = object.picture().filter(|image| image.id == placement.id) { | |
| 408 | return picture(image); | |
| 409 | } | |
| 410 | match (object, object.layout()) { | |
| 411 | (page::Content::ReadOnly(object), Some((id, _))) | |
| 412 | if id == placement.id && !matches!(object.source, PageObject::Outline(_)) => | |
| 413 | { | |
| 414 | return Err(refused("Unsupported objects cannot be moved")); | |
| 417 | 415 | } |
| 418 | page::Content::ReadOnly(object) if object.source.id() == placement.id => { | |
| 419 | return match &object.source { | |
| 420 | PageObject::Outline(source) => { | |
| 421 | layout_ops(source.id, &old, &moved(&source.layout)) | |
| 422 | } | |
| 423 | PageObject::Image(image) => picture(image), | |
| 424 | _ => Err(refused("Unsupported objects cannot be moved")), | |
| 425 | }; | |
| 416 | (_, Some((id, layout))) if id == placement.id => { | |
| 417 | return layout_ops(id, &old, &moved(layout)); | |
| 426 | 418 | } |
| 427 | 419 | _ => {} |
| 428 | 420 | } |
| ... | ... | @@ -467,6 +459,7 @@ impl CanvasEditor { |
| 467 | 459 | identity: None, |
| 468 | 460 | created: None, |
| 469 | 461 | margin_origin: [0.0; 2], |
| 462 | rtl: false, | |
| 470 | 463 | color: None, |
| 471 | 464 | rule_lines: None, |
| 472 | 465 | objects, |
crates/canvas/src/editor/page.rs+25-40| ... | ... | @@ -10,19 +10,19 @@ use std::collections::BTreeMap; |
| 10 | 10 | |
| 11 | 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. |
| 12 | 12 | pub(crate) struct TitleArea { |
| 13 | pub id: onestore::ExGuid, | |
| 14 | pub date: Option<onestore::ExGuid>, | |
| 15 | pub layout: onestore::document::Layout, | |
| 16 | pub origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>, | |
| 13 | pub(crate) id: onestore::ExGuid, | |
| 14 | pub(crate) date: Option<onestore::ExGuid>, | |
| 15 | pub(crate) layout: onestore::document::Layout, | |
| 16 | pub(crate) origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>, | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | pub(crate) struct Import { |
| 20 | pub objects: Vec<Content>, | |
| 21 | pub outlines: Vec<TextOutline>, | |
| 22 | pub date: Option<PageDate>, | |
| 23 | pub areas: Vec<TitleArea>, | |
| 20 | pub(crate) objects: Vec<Content>, | |
| 21 | pub(crate) outlines: Vec<TextOutline>, | |
| 22 | pub(crate) date: Option<PageDate>, | |
| 23 | pub(crate) areas: Vec<TitleArea>, | |
| 24 | 24 | /// The paragraph each outline holding no text shows after its objects, not stored. |
| 25 | pub provisional: BTreeMap<onestore::ExGuid, PageParagraph>, | |
| 25 | pub(crate) provisional: BTreeMap<onestore::ExGuid, PageParagraph>, | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | pub(crate) enum Content { |
| ... | ... | @@ -95,9 +95,8 @@ impl Content { |
| 95 | 95 | let Self::File { source, layout } = self else { |
| 96 | 96 | return None; |
| 97 | 97 | }; |
| 98 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 99 | let [x0, y0, x1, y1] = layout.bounds(); | |
| 100 | Some((source, [x + x0, y + y0, x + x1, y + y1])) | |
| 98 | let origin = crate::origin(&source.layout); | |
| 99 | Some((source, crate::translated(layout.bounds(), origin))) | |
| 101 | 100 | } |
| 102 | 101 | |
| 103 | 102 | /// A tagged picture or file: its identity, note tags and bounds in page points. |
| ... | ... | @@ -106,7 +105,7 @@ impl Content { |
| 106 | 105 | ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { |
| 107 | 106 | let (id, tags, bounds) = match self { |
| 108 | 107 | Self::Image(image) => { |
| 109 | let [x, y] = [image.layout.x, image.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 108 | let [x, y] = crate::origin(&image.layout); | |
| 110 | 109 | let [width, height] = crate::outline::image_size(image)?; |
| 111 | 110 | (image.id, &image.tags, [x, y, x + width, y + height]) |
| 112 | 111 | } |
| ... | ... | @@ -143,14 +142,11 @@ impl ReadOnlyObject { |
| 143 | 142 | [0.0; 2] |
| 144 | 143 | }; |
| 145 | 144 | let layout = source.layout(); |
| 146 | let x = layout.x.unwrap_or(0.0) + offset[0]; | |
| 147 | let y = layout.y.unwrap_or(0.0) + offset[1]; | |
| 148 | 145 | let width = layout.max_width.unwrap_or(160.0); |
| 149 | 146 | let height = layout.max_height.unwrap_or(42.0); |
| 150 | if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { | |
| 147 | if ![width, height].iter().all(|v| v.is_finite() && *v > 0.0) { | |
| 151 | 148 | return Err(EditorError::InvalidGeometry); |
| 152 | 149 | } |
| 153 | let width = width.max(160.0); | |
| 154 | 150 | let label = engine.layout( |
| 155 | 151 | &Paragraph::new( |
| 156 | 152 | message.into(), |
| ... | ... | @@ -160,21 +156,21 @@ impl ReadOnlyObject { |
| 160 | 156 | ..Default::default() |
| 161 | 157 | }, |
| 162 | 158 | ), |
| 163 | width - 16.0, | |
| 159 | width.max(160.0) - 16.0, | |
| 164 | 160 | )?; |
| 165 | let height = height.max(label.height() + 16.0); | |
| 166 | let rect = [x, y, x + width, y + height]; | |
| 167 | if rect.iter().any(|v| !v.is_finite()) { | |
| 168 | return Err(EditorError::InvalidGeometry); | |
| 169 | } | |
| 170 | Ok(Box::new(Self { | |
| 161 | let object = Box::new(Self { | |
| 171 | 162 | source, |
| 172 | 163 | message, |
| 173 | 164 | label, |
| 174 | 165 | offset, |
| 175 | })) | |
| 166 | }); | |
| 167 | if object.rect().iter().any(|v| !v.is_finite()) { | |
| 168 | return Err(EditorError::InvalidGeometry); | |
| 169 | } | |
| 170 | Ok(object) | |
| 176 | 171 | } |
| 177 | pub fn rect(&self) -> [f32; 4] { | |
| 172 | ||
| 173 | pub(crate) fn rect(&self) -> [f32; 4] { | |
| 178 | 174 | let layout = self.source.layout(); |
| 179 | 175 | let x = layout.x.unwrap_or(0.0) + self.offset[0]; |
| 180 | 176 | let y = layout.y.unwrap_or(0.0) + self.offset[1]; |
| ... | ... | @@ -206,10 +202,7 @@ pub(crate) fn build( |
| 206 | 202 | for object in std::mem::take(&mut page.objects) { |
| 207 | 203 | match &object { |
| 208 | 204 | PageObject::Outline(outline) => { |
| 209 | let origin = [ | |
| 210 | outline.layout.x.unwrap_or(0.0), | |
| 211 | outline.layout.y.unwrap_or(0.0), | |
| 212 | ]; | |
| 205 | let origin = crate::origin(&outline.layout); | |
| 213 | 206 | if origin.iter().any(|v| !v.is_finite()) { |
| 214 | 207 | return Err(EditorError::InvalidGeometry); |
| 215 | 208 | } |
| ... | ... | @@ -377,10 +370,7 @@ pub(crate) fn build( |
| 377 | 370 | objects.push(Content::unavailable(object, engine)?); |
| 378 | 371 | continue; |
| 379 | 372 | }; |
| 380 | let origin = [ | |
| 381 | source.layout.x.unwrap_or(0.0), | |
| 382 | source.layout.y.unwrap_or(0.0), | |
| 383 | ]; | |
| 373 | let origin = crate::origin(&source.layout); | |
| 384 | 374 | if origin.iter().any(|v| !v.is_finite()) |
| 385 | 375 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) |
| 386 | 376 | || !(origin[0] + size[0]).is_finite() |
| ... | ... | @@ -435,12 +425,7 @@ pub(crate) fn build( |
| 435 | 425 | /// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page |
| 436 | 426 | /// points. |
| 437 | 427 | pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { |
| 438 | let [x, y] = ink_offset(ink); | |
| 439 | stroke_bounds(ink).map(|[x0, y0, x1, y1]| [x0 + x, y0 + y, x1 + x, y1 + y]) | |
| 440 | } | |
| 441 | ||
| 442 | pub(crate) fn ink_offset(ink: &Ink) -> [f32; 2] { | |
| 443 | [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)] | |
| 428 | stroke_bounds(ink).map(|bounds| crate::translated(bounds, crate::origin(&ink.layout))) | |
| 444 | 429 | } |
| 445 | 430 | |
| 446 | 431 | /// The painted extent of every stroke before the drawing's offset. |
crates/canvas/src/editor/recording.rs+2-2| ... | ... | @@ -259,7 +259,7 @@ const BUTTON: [f32; 2] = [10.5, 24.0]; |
| 259 | 259 | |
| 260 | 260 | impl CanvasEditor { |
| 261 | 261 | /// The recording `id` names, in an outline or on the page. |
| 262 | pub fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> { | |
| 262 | pub(crate) fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> { | |
| 263 | 263 | let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id); |
| 264 | 264 | self.objects |
| 265 | 265 | .iter() |
| ... | ... | @@ -293,7 +293,7 @@ impl CanvasEditor { |
| 293 | 293 | /// The play button OneNote shows for document point `point`: beside the linked note or |
| 294 | 294 | /// the recording level with it, over its outline or in the margin before it. Gives the |
| 295 | 295 | /// button's square, the recording and the moment it plays from. |
| 296 | pub fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> { | |
| 296 | pub(crate) fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> { | |
| 297 | 297 | let [x, y] = point; |
| 298 | 298 | let outline = self.visible_outlines().find(|outline| { |
| 299 | 299 | let bounds = outline.bounds(); |
crates/canvas/src/editor/space.rs+6-17| ... | ... | @@ -68,7 +68,7 @@ impl CanvasEditor { |
| 68 | 68 | layout, |
| 69 | 69 | below_title: None, |
| 70 | 70 | } => { |
| 71 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 71 | let [x, y] = crate::origin(&source.layout); | |
| 72 | 72 | (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) |
| 73 | 73 | } |
| 74 | 74 | page::Content::Image(image) => { |
| ... | ... | @@ -109,13 +109,13 @@ impl CanvasEditor { |
| 109 | 109 | |
| 110 | 110 | /// How far back from `line` Insert Space can take the page along `axis`: never over what |
| 111 | 111 | /// stays before the line, so at most zero. |
| 112 | pub fn space_limit(&self, axis: usize, line: f32) -> f32 { | |
| 112 | pub(crate) fn space_limit(&self, axis: usize, line: f32) -> f32 { | |
| 113 | 113 | (self.spaced(axis, line).1 - line).min(0.0) |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | 116 | /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along |
| 117 | 117 | /// `axis` moves. |
| 118 | pub fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { | |
| 118 | pub(crate) fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { | |
| 119 | 119 | self.spaced(axis, line) |
| 120 | 120 | .0 |
| 121 | 121 | .into_iter() |
| ... | ... | @@ -156,7 +156,7 @@ impl CanvasEditor { |
| 156 | 156 | let layout = self.object_layout_mut(id).unwrap(); |
| 157 | 157 | // Undo restores an unset coordinate as the zero it stands for, as ops |
| 158 | 158 | // cannot unset one. |
| 159 | let origin = [layout.x, layout.y].map(|v| v.unwrap_or(0.0)); | |
| 159 | let origin = crate::origin(layout); | |
| 160 | 160 | let previous = origin.map(Some); |
| 161 | 161 | [layout.x, layout.y] = shift(origin).map(Some); |
| 162 | 162 | self.undo.push(History::Position { |
| ... | ... | @@ -182,27 +182,16 @@ impl CanvasEditor { |
| 182 | 182 | } |
| 183 | 183 | }; |
| 184 | 184 | if let Err(error) = result { |
| 185 | self.group(depth); | |
| 185 | self.group(depth, false); | |
| 186 | 186 | return Err(error); |
| 187 | 187 | } |
| 188 | 188 | } |
| 189 | self.group(depth); | |
| 189 | self.group(depth, false); | |
| 190 | 190 | self.redo.clear(); |
| 191 | 191 | self.preferred_x = None; |
| 192 | 192 | Ok(true) |
| 193 | 193 | } |
| 194 | 194 | |
| 195 | /// Makes the history entries past `depth` one undo step that leaves the selection alone. | |
| 196 | fn group(&mut self, depth: usize) { | |
| 197 | let entries = self.undo.split_off(depth); | |
| 198 | if !entries.is_empty() { | |
| 199 | self.undo.push(History::Group { | |
| 200 | entries, | |
| 201 | page: false, | |
| 202 | }); | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | 195 | /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which |
| 207 | 196 | /// takes the rest of the outline's layout; returns each root `part` then holds with the |
| 208 | 197 | /// parent it had. |
crates/canvas/src/editor/table.rs+83-113| ... | ... | @@ -65,16 +65,59 @@ fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> { |
| 65 | 65 | Default::default(), |
| 66 | 66 | )?, |
| 67 | 67 | }; |
| 68 | new_cell(source.indents.clone(), source.shading, vec![node]) | |
| 69 | } | |
| 70 | ||
| 71 | /// A new cell of `paragraphs`. | |
| 72 | fn new_cell( | |
| 73 | indents: Vec<f32>, | |
| 74 | shading: Option<u32>, | |
| 75 | paragraphs: Vec<PageParagraph>, | |
| 76 | ) -> Result<TableCell, EditError> { | |
| 68 | 77 | Ok(TableCell { |
| 69 | 78 | id: new_id()?, |
| 70 | 79 | layout: Default::default(), |
| 71 | indents: source.indents.clone(), | |
| 72 | shading: source.shading, | |
| 73 | paragraphs: vec![node], | |
| 80 | indents, | |
| 81 | shading, | |
| 82 | paragraphs, | |
| 74 | 83 | unsupported: Vec::new(), |
| 75 | 84 | }) |
| 76 | 85 | } |
| 77 | 86 | |
| 87 | /// A new bordered table of `rows` and `columns` new columns, in `source`'s place in the | |
| 88 | /// outline's tree. | |
| 89 | fn table_paragraph( | |
| 90 | source: &PageParagraph, | |
| 91 | columns: usize, | |
| 92 | rows: Vec<TableRow>, | |
| 93 | ) -> Result<PageParagraph, EditError> { | |
| 94 | Ok(PageParagraph { | |
| 95 | id: new_id()?, | |
| 96 | parent: source.parent, | |
| 97 | level: source.level, | |
| 98 | style: None, | |
| 99 | format: source.format.clone(), | |
| 100 | lists: Vec::new(), | |
| 101 | tags: Vec::new(), | |
| 102 | media: Default::default(), | |
| 103 | collapsed: false, | |
| 104 | content: ParagraphContent::Table(Table { | |
| 105 | id: new_id()?, | |
| 106 | columns: vec![ | |
| 107 | TableColumn { | |
| 108 | width: COLUMN_WIDTH, | |
| 109 | locked: false | |
| 110 | }; | |
| 111 | columns | |
| 112 | ], | |
| 113 | rows, | |
| 114 | borders: Some(true), | |
| 115 | layout: Default::default(), | |
| 116 | tags: Vec::new(), | |
| 117 | }), | |
| 118 | }) | |
| 119 | } | |
| 120 | ||
| 78 | 121 | fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> { |
| 79 | 122 | let mut content = leaves(&cell.paragraphs, None); |
| 80 | 123 | let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?; |
| ... | ... | @@ -250,7 +293,11 @@ impl CanvasEditor { |
| 250 | 293 | impl TextOutline { |
| 251 | 294 | /// The table column whose right border lies within `reach` of outline-local `point`, as |
| 252 | 295 | /// `(table, column, width)`. |
| 253 | pub fn column_border(&self, point: [f32; 2], reach: f32) -> Option<(ExGuid, usize, f32)> { | |
| 296 | pub(crate) fn column_border( | |
| 297 | &self, | |
| 298 | point: [f32; 2], | |
| 299 | reach: f32, | |
| 300 | ) -> Option<(ExGuid, usize, f32)> { | |
| 254 | 301 | let columns = descendants(self.document.nodes(), None) |
| 255 | 302 | .filter_map(|(_, _, node)| match &node.content { |
| 256 | 303 | ParagraphContent::Table(table) => Some((table.id, table.columns.len())), |
| ... | ... | @@ -276,7 +323,7 @@ impl TextOutline { |
| 276 | 323 | impl CanvasEditor { |
| 277 | 324 | /// The active outline with column `column` of `table` `width` wide, as a border drag |
| 278 | 325 | /// shows it before release. |
| 279 | pub fn preview_column( | |
| 326 | pub(crate) fn preview_column( | |
| 280 | 327 | &self, |
| 281 | 328 | engine: &mut TextEngine, |
| 282 | 329 | table: ExGuid, |
| ... | ... | @@ -383,53 +430,23 @@ impl CanvasEditor { |
| 383 | 430 | let text = &node.text().unwrap().text; |
| 384 | 431 | let end = text.utf16_offset(text.text().len())?; |
| 385 | 432 | let format = text.format_at(caret.offset)?; |
| 386 | let cell = || -> Result<TableCell, EditError> { | |
| 433 | let empty = || -> Result<TableCell, EditError> { | |
| 387 | 434 | let mut paragraph = crate::document::node( |
| 388 | 435 | Paragraph::new(String::new(), format.clone()), |
| 389 | 436 | node.format.clone(), |
| 390 | 437 | )?; |
| 391 | 438 | paragraph.style = node.style; |
| 392 | Ok(TableCell { | |
| 393 | id: new_id()?, | |
| 394 | layout: Default::default(), | |
| 395 | indents: outline.indents.clone(), | |
| 396 | shading: None, | |
| 397 | paragraphs: vec![paragraph], | |
| 398 | unsupported: Vec::new(), | |
| 399 | }) | |
| 400 | }; | |
| 401 | let table = PageParagraph { | |
| 402 | id: new_id()?, | |
| 403 | parent: node.parent, | |
| 404 | level: node.level, | |
| 405 | style: None, | |
| 406 | format: node.format.clone(), | |
| 407 | lists: Vec::new(), | |
| 408 | tags: Vec::new(), | |
| 409 | media: Default::default(), | |
| 410 | collapsed: false, | |
| 411 | content: ParagraphContent::Table(onestore::page::Table { | |
| 412 | id: new_id()?, | |
| 413 | columns: vec![ | |
| 414 | TableColumn { | |
| 415 | width: COLUMN_WIDTH, | |
| 416 | locked: false | |
| 417 | }; | |
| 418 | columns | |
| 419 | ], | |
| 420 | rows: (0..rows) | |
| 421 | .map(|_| { | |
| 422 | Ok(TableRow { | |
| 423 | id: new_id()?, | |
| 424 | cells: (0..columns).map(|_| cell()).collect::<Result<_, _>>()?, | |
| 425 | }) | |
| 426 | }) | |
| 427 | .collect::<Result<_, EditError>>()?, | |
| 428 | borders: Some(true), | |
| 429 | layout: Default::default(), | |
| 430 | tags: Vec::new(), | |
| 431 | }), | |
| 439 | new_cell(outline.indents.clone(), None, vec![paragraph]) | |
| 432 | 440 | }; |
| 441 | let rows = (0..rows) | |
| 442 | .map(|_| { | |
| 443 | Ok(TableRow { | |
| 444 | id: new_id()?, | |
| 445 | cells: (0..columns).map(|_| empty()).collect::<Result<_, _>>()?, | |
| 446 | }) | |
| 447 | }) | |
| 448 | .collect::<Result<_, EditError>>()?; | |
| 449 | let table = table_paragraph(node, columns, rows)?; | |
| 433 | 450 | let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id); |
| 434 | 451 | let childless = !outline.document.container(container)?.iter().any(parent); |
| 435 | 452 | let (edit, first) = if end == 0 && childless { |
| ... | ... | @@ -489,12 +506,8 @@ impl CanvasEditor { |
| 489 | 506 | let format = source.text().unwrap().text.format_at(focus.offset)?; |
| 490 | 507 | let mut split = outline.document.replace( |
| 491 | 508 | focus..focus, |
| 492 | vec![ | |
| 493 | Paragraph::new(String::new(), format.clone()), | |
| 494 | Paragraph::new(String::new(), format.clone()), | |
| 495 | ], | |
| 509 | vec![Paragraph::new(String::new(), format.clone()); 2], | |
| 496 | 510 | )?; |
| 497 | let id = new_id()?; | |
| 498 | 511 | // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ |
| 499 | 512 | // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). |
| 500 | 513 | let children = split.replacement.split_off(2); |
| ... | ... | @@ -511,44 +524,20 @@ impl CanvasEditor { |
| 511 | 524 | paragraph.level = 1; |
| 512 | 525 | paragraph.parent = None; |
| 513 | 526 | paragraph.lists.clear(); |
| 514 | Ok(TableCell { | |
| 515 | id: new_id()?, | |
| 516 | layout: Default::default(), | |
| 517 | indents: outline.indents.clone(), | |
| 518 | shading: None, | |
| 519 | paragraphs: vec![paragraph], | |
| 520 | unsupported: Vec::new(), | |
| 521 | }) | |
| 527 | new_cell(outline.indents.clone(), None, vec![paragraph]) | |
| 522 | 528 | }) |
| 523 | 529 | .collect::<Result<Vec<_>, EditError>>()?; |
| 530 | let row = TableRow { | |
| 531 | id: new_id()?, | |
| 532 | cells, | |
| 533 | }; | |
| 524 | 534 | let wrapper = PageParagraph { |
| 525 | id, | |
| 526 | parent: source.parent, | |
| 527 | level: source.level, | |
| 528 | style: None, | |
| 529 | format: source.format.clone(), | |
| 530 | 535 | lists, |
| 531 | 536 | tags, |
| 532 | media: Default::default(), | |
| 533 | 537 | collapsed, |
| 534 | content: ParagraphContent::Table(onestore::page::Table { | |
| 535 | id: new_id()?, | |
| 536 | columns: vec![ | |
| 537 | TableColumn { | |
| 538 | width: COLUMN_WIDTH, | |
| 539 | locked: false | |
| 540 | }; | |
| 541 | 2 | |
| 542 | ], | |
| 543 | rows: vec![TableRow { | |
| 544 | id: new_id()?, | |
| 545 | cells, | |
| 546 | }], | |
| 547 | borders: Some(true), | |
| 548 | layout: Default::default(), | |
| 549 | tags: Vec::new(), | |
| 550 | }), | |
| 538 | ..table_paragraph(source, 2, vec![row])? | |
| 551 | 539 | }; |
| 540 | let id = wrapper.id; | |
| 552 | 541 | split.replacement.push(wrapper); |
| 553 | 542 | split |
| 554 | 543 | .replacement |
| ... | ... | @@ -598,22 +587,12 @@ impl CanvasEditor { |
| 598 | 587 | let format = source.text().unwrap().text.format_at(focus.offset)?; |
| 599 | 588 | let split = outline.document.replace( |
| 600 | 589 | focus..focus, |
| 601 | vec![ | |
| 602 | Paragraph::new(String::new(), format.clone()), | |
| 603 | Paragraph::new(String::new(), format.clone()), | |
| 604 | ], | |
| 590 | vec![Paragraph::new(String::new(), format.clone()); 2], | |
| 605 | 591 | )?; |
| 606 | let cell = &mut table.rows[0].cells[location.column]; | |
| 607 | cell.paragraphs.splice(split.range, split.replacement); | |
| 608 | let paragraphs = cell.paragraphs.split_off(local + 1); | |
| 609 | let target = TableCell { | |
| 610 | id: new_id()?, | |
| 611 | layout: Default::default(), | |
| 612 | indents: cell.indents.clone(), | |
| 613 | shading: cell.shading, | |
| 614 | paragraphs, | |
| 615 | unsupported: Vec::new(), | |
| 616 | }; | |
| 592 | let source = &mut table.rows[0].cells[location.column]; | |
| 593 | source.paragraphs.splice(split.range, split.replacement); | |
| 594 | let paragraphs = source.paragraphs.split_off(local + 1); | |
| 595 | let target = new_cell(source.indents.clone(), source.shading, paragraphs)?; | |
| 617 | 596 | table.rows[0].cells.push(target); |
| 618 | 597 | table.columns.push(TableColumn { |
| 619 | 598 | width: COLUMN_WIDTH, |
| ... | ... | @@ -722,7 +701,7 @@ impl CanvasEditor { |
| 722 | 701 | let ParagraphContent::Table(table) = &mut wrapper.content else { |
| 723 | 702 | unreachable!() |
| 724 | 703 | }; |
| 725 | let (replacement, next) = if exit { | |
| 704 | let replacement = if exit { | |
| 726 | 705 | table.rows.pop(); |
| 727 | 706 | let blank = || { |
| 728 | 707 | Ok::<_, EditError>(PageParagraph { |
| ... | ... | @@ -732,14 +711,7 @@ impl CanvasEditor { |
| 732 | 711 | ..self.blank_paragraph(source)? |
| 733 | 712 | }) |
| 734 | 713 | }; |
| 735 | let (first, second) = (blank()?, blank()?); | |
| 736 | ( | |
| 737 | vec![wrapper, first, second], | |
| 738 | TextPosition { | |
| 739 | paragraph: focus.paragraph + 1, | |
| 740 | offset: 0, | |
| 741 | }, | |
| 742 | ) | |
| 714 | vec![wrapper, blank()?, blank()?] | |
| 743 | 715 | } else { |
| 744 | 716 | let cells = row |
| 745 | 717 | .cells |
| ... | ... | @@ -750,13 +722,11 @@ impl CanvasEditor { |
| 750 | 722 | id: new_id()?, |
| 751 | 723 | cells, |
| 752 | 724 | }); |
| 753 | ( | |
| 754 | vec![wrapper], | |
| 755 | TextPosition { | |
| 756 | paragraph: focus.paragraph + 1, | |
| 757 | offset: 0, | |
| 758 | }, | |
| 759 | ) | |
| 725 | vec![wrapper] | |
| 726 | }; | |
| 727 | let next = TextPosition { | |
| 728 | paragraph: focus.paragraph + 1, | |
| 729 | offset: 0, | |
| 760 | 730 | }; |
| 761 | 731 | self.commit( |
| 762 | 732 | engine, |
crates/canvas/src/gpu/page.rs+50-105| ... | ... | @@ -301,7 +301,7 @@ impl PageScene { |
| 301 | 301 | |
| 302 | 302 | /// Follows `editor` after an edit or undo brought page-level pictures the scene has not |
| 303 | 303 | /// seen, as `refresh` does. |
| 304 | pub fn follow( | |
| 304 | pub(crate) fn follow( | |
| 305 | 305 | &mut self, |
| 306 | 306 | editor: &mut CanvasEditor, |
| 307 | 307 | engine: &mut TextEngine, |
| ... | ... | @@ -415,7 +415,7 @@ impl PageScene { |
| 415 | 415 | /// this thread; `waker` is woken when one lands and the page should be drawn again. |
| 416 | 416 | /// Call before collecting each frame's primitives. True once everything in view shows |
| 417 | 417 | /// its raster for this paper and scale. |
| 418 | pub fn update_pictures( | |
| 418 | pub(crate) fn update_pictures( | |
| 419 | 419 | &mut self, |
| 420 | 420 | editor: Option<&CanvasEditor>, |
| 421 | 421 | view: [f32; 4], |
| ... | ... | @@ -468,37 +468,32 @@ impl PageScene { |
| 468 | 468 | [x, y]: [f32; 2], |
| 469 | 469 | rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, |
| 470 | 470 | ) { |
| 471 | rects.extend(layout.objects.iter().map(|object| { | |
| 472 | let [x0, y0, x1, y1] = object.rect; | |
| 473 | (object.id, [x0 + x, y0 + y, x1 + x, y1 + y]) | |
| 474 | })); | |
| 471 | rects.extend( | |
| 472 | layout | |
| 473 | .objects | |
| 474 | .iter() | |
| 475 | .map(|object| (object.id, crate::translated(object.rect, [x, y]))), | |
| 476 | ); | |
| 475 | 477 | } |
| 476 | 478 | let mut rects = Vec::new(); |
| 477 | 479 | for content in self.objects(editor).into_iter().flatten() { |
| 478 | 480 | match content { |
| 479 | 481 | Content::Image(source) => { |
| 480 | let [x, y] = [ | |
| 481 | source.layout.x.unwrap_or(0.0), | |
| 482 | source.layout.y.unwrap_or(0.0), | |
| 483 | ]; | |
| 482 | let [x, y] = crate::origin(&source.layout); | |
| 484 | 483 | if let Some([width, height]) = crate::outline::image_size(source) { |
| 485 | 484 | rects.push((source.id, [x, y, x + width, y + height])); |
| 486 | 485 | } |
| 487 | 486 | } |
| 488 | 487 | Content::File { source, layout } => { |
| 489 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 490 | let [x0, y0, x1, y1] = layout.rect; | |
| 491 | rects.push((source.id, [x + x0, y + y0, x + x1, y + y1])); | |
| 488 | let origin = crate::origin(&source.layout); | |
| 489 | rects.push((source.id, crate::translated(layout.rect, origin))); | |
| 492 | 490 | } |
| 493 | 491 | Content::Outline { |
| 494 | 492 | source, |
| 495 | 493 | layout, |
| 496 | 494 | below_title, |
| 497 | 495 | } => { |
| 498 | let origin = [ | |
| 499 | source.layout.x.unwrap_or(0.0), | |
| 500 | source.layout.y.unwrap_or(0.0), | |
| 501 | ]; | |
| 496 | let origin = crate::origin(&source.layout); | |
| 502 | 497 | if let Ok(origin) = outline_origin(origin, *below_title, editor) { |
| 503 | 498 | outline(layout, origin, &mut rects); |
| 504 | 499 | } |
| ... | ... | @@ -518,7 +513,7 @@ impl PageScene { |
| 518 | 513 | |
| 519 | 514 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. |
| 520 | 515 | /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. |
| 521 | pub fn append_outline_objects<'a>( | |
| 516 | pub(crate) fn append_outline_objects<'a>( | |
| 522 | 517 | &'a self, |
| 523 | 518 | outline: &'a crate::outline::OutlineLayout, |
| 524 | 519 | origin: [f32; 2], |
| ... | ... | @@ -541,15 +536,9 @@ impl PageScene { |
| 541 | 536 | paper: Paper, |
| 542 | 537 | primitives: &mut Vec<Primitive<'a>>, |
| 543 | 538 | ) { |
| 544 | let [x0, y0, x1, y1] = object.rect; | |
| 545 | 539 | let rect = match moving { |
| 546 | 540 | Some((id, rect)) if id == object.id => rect, |
| 547 | _ => [ | |
| 548 | x0 + origin[0], | |
| 549 | y0 + origin[1], | |
| 550 | x1 + origin[0], | |
| 551 | y1 + origin[1], | |
| 552 | ], | |
| 541 | _ => crate::translated(object.rect, origin), | |
| 553 | 542 | }; |
| 554 | 543 | let picture = self.pictures.get(&object.id); |
| 555 | 544 | if let Some(image) = picture.and_then(Picture::image) { |
| ... | ... | @@ -644,7 +633,7 @@ impl PageScene { |
| 644 | 633 | |
| 645 | 634 | /// Bounds of noneditable content in page coordinates, each with whether OneNote's view |
| 646 | 635 | /// leaves room beyond it: a picture's corner is as far as the view reaches. |
| 647 | pub fn content_bounds<'a>( | |
| 636 | pub(crate) fn content_bounds<'a>( | |
| 648 | 637 | &'a self, |
| 649 | 638 | editor: &'a CanvasEditor, |
| 650 | 639 | ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a { |
| ... | ... | @@ -657,15 +646,9 @@ impl PageScene { |
| 657 | 646 | layout, |
| 658 | 647 | below_title, |
| 659 | 648 | } => { |
| 660 | let origin = outline_origin( | |
| 661 | [ | |
| 662 | source.layout.x.unwrap_or(0.0), | |
| 663 | source.layout.y.unwrap_or(0.0), | |
| 664 | ], | |
| 665 | *below_title, | |
| 666 | Some(editor), | |
| 667 | ) | |
| 668 | .ok()?; | |
| 649 | let origin = | |
| 650 | outline_origin(crate::origin(&source.layout), *below_title, Some(editor)) | |
| 651 | .ok()?; | |
| 669 | 652 | Some(( |
| 670 | 653 | [ |
| 671 | 654 | origin[0], |
| ... | ... | @@ -679,10 +662,7 @@ impl PageScene { |
| 679 | 662 | Content::Date { below_title } => { |
| 680 | 663 | let date = editor.date()?; |
| 681 | 664 | let origin = outline_origin( |
| 682 | [ | |
| 683 | date.source().layout.x.unwrap_or(0.0), | |
| 684 | date.source().layout.y.unwrap_or(0.0), | |
| 685 | ], | |
| 665 | crate::origin(&date.source().layout), | |
| 686 | 666 | *below_title, |
| 687 | 667 | Some(editor), |
| 688 | 668 | ) |
| ... | ... | @@ -699,7 +679,7 @@ impl PageScene { |
| 699 | 679 | )) |
| 700 | 680 | } |
| 701 | 681 | Content::Image(source) => { |
| 702 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 682 | let [x, y] = crate::origin(&source.layout); | |
| 703 | 683 | let [width, height] = crate::outline::image_size(source)?; |
| 704 | 684 | Some(([x, y, x + width, y + height], false)) |
| 705 | 685 | } |
| ... | ... | @@ -710,7 +690,7 @@ impl PageScene { |
| 710 | 690 | }) |
| 711 | 691 | } |
| 712 | 692 | |
| 713 | pub fn date_fields( | |
| 693 | pub(crate) fn date_fields( | |
| 714 | 694 | &self, |
| 715 | 695 | editor: &CanvasEditor, |
| 716 | 696 | ) -> impl Iterator<Item = (DateField, [f32; 4])> { |
| ... | ... | @@ -721,13 +701,9 @@ impl PageScene { |
| 721 | 701 | .into_iter() |
| 722 | 702 | .flatten() |
| 723 | 703 | .find_map(|object| match object { |
| 724 | Content::Date { below_title } => Some(( | |
| 725 | [ | |
| 726 | date.source().layout.x.unwrap_or(0.0), | |
| 727 | date.source().layout.y.unwrap_or(0.0), | |
| 728 | ], | |
| 729 | *below_title, | |
| 730 | )), | |
| 704 | Content::Date { below_title } => { | |
| 705 | Some((crate::origin(&date.source().layout), *below_title)) | |
| 706 | } | |
| 731 | 707 | _ => None, |
| 732 | 708 | }) |
| 733 | 709 | && let Ok(origin) = outline_origin(origin, title, Some(editor)) |
| ... | ... | @@ -794,10 +770,7 @@ impl PageScene { |
| 794 | 770 | } |
| 795 | 771 | } |
| 796 | 772 | Content::Image(source) if !source.background => { |
| 797 | let [x, y] = [ | |
| 798 | source.layout.x.unwrap_or(0.0), | |
| 799 | source.layout.y.unwrap_or(0.0), | |
| 800 | ]; | |
| 773 | let [x, y] = crate::origin(&source.layout); | |
| 801 | 774 | if let Some([width, height]) = crate::outline::image_size(source) |
| 802 | 775 | && (x..=x + width).contains(&point[0]) |
| 803 | 776 | && (y..=y + height).contains(&point[1]) |
| ... | ... | @@ -846,14 +819,7 @@ impl PageScene { |
| 846 | 819 | layout, |
| 847 | 820 | below_title, |
| 848 | 821 | } => { |
| 849 | let [x, y] = outline_origin( | |
| 850 | [ | |
| 851 | source.layout.x.unwrap_or(0.0), | |
| 852 | source.layout.y.unwrap_or(0.0), | |
| 853 | ], | |
| 854 | *below_title, | |
| 855 | None, | |
| 856 | )?; | |
| 822 | let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?; | |
| 857 | 823 | cover([x, y, x + layout.size[0], y + layout.size[1]], false); |
| 858 | 824 | for paragraph in &layout.paragraphs { |
| 859 | 825 | let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; |
| ... | ... | @@ -869,10 +835,7 @@ impl PageScene { |
| 869 | 835 | } |
| 870 | 836 | } |
| 871 | 837 | Content::Image(source) => { |
| 872 | let [x, y] = [ | |
| 873 | source.layout.x.unwrap_or(0.0), | |
| 874 | source.layout.y.unwrap_or(0.0), | |
| 875 | ]; | |
| 838 | let [x, y] = crate::origin(&source.layout); | |
| 876 | 839 | let size = match self.backgrounds.get(&source.id) { |
| 877 | 840 | Some(art) => Some(art.size), |
| 878 | 841 | None => crate::outline::image_size(source), |
| ... | ... | @@ -912,7 +875,7 @@ impl PageScene { |
| 912 | 875 | |
| 913 | 876 | /// `moving` draws one picture at a previewed rectangle instead of its stored layout. |
| 914 | 877 | /// Outlines the editor does not hold draw their tags' symbols. |
| 915 | pub fn append_primitives_with<'a, E: From<SceneError>>( | |
| 878 | pub(crate) fn append_primitives_with<'a, E: From<SceneError>>( | |
| 916 | 879 | &'a self, |
| 917 | 880 | primitives: &mut Vec<Primitive<'a>>, |
| 918 | 881 | offset: [f32; 2], |
| ... | ... | @@ -927,39 +890,16 @@ impl PageScene { |
| 927 | 890 | source, |
| 928 | 891 | below_title, |
| 929 | 892 | .. |
| 930 | } => outline_origin( | |
| 931 | [ | |
| 932 | source.layout.x.unwrap_or(0.0), | |
| 933 | source.layout.y.unwrap_or(0.0), | |
| 934 | ], | |
| 935 | *below_title, | |
| 936 | editor, | |
| 937 | )?, | |
| 893 | } => outline_origin(crate::origin(&source.layout), *below_title, editor)?, | |
| 938 | 894 | Content::Date { below_title } => { |
| 939 | 895 | let date = editor |
| 940 | 896 | .and_then(CanvasEditor::date) |
| 941 | 897 | .ok_or(SceneError::MissingOutline)?; |
| 942 | outline_origin( | |
| 943 | [ | |
| 944 | date.source().layout.x.unwrap_or(0.0), | |
| 945 | date.source().layout.y.unwrap_or(0.0), | |
| 946 | ], | |
| 947 | *below_title, | |
| 948 | editor, | |
| 949 | )? | |
| 898 | outline_origin(crate::origin(&date.source().layout), *below_title, editor)? | |
| 950 | 899 | } |
| 951 | Content::Image(source) => [ | |
| 952 | source.layout.x.unwrap_or(0.0), | |
| 953 | source.layout.y.unwrap_or(0.0), | |
| 954 | ], | |
| 900 | Content::Image(source) => crate::origin(&source.layout), | |
| 955 | 901 | Content::ReadOnly(object) => { |
| 956 | let [x0, y0, x1, y1] = object.rect(); | |
| 957 | let rect = [ | |
| 958 | x0 + offset[0], | |
| 959 | y0 + offset[1], | |
| 960 | x1 + offset[0], | |
| 961 | y1 + offset[1], | |
| 962 | ]; | |
| 902 | let rect = crate::translated(object.rect(), offset); | |
| 963 | 903 | append_placeholder(rect, paper, primitives); |
| 964 | 904 | primitives.push(Primitive::Text { |
| 965 | 905 | clip: None, |
| ... | ... | @@ -974,7 +914,7 @@ impl PageScene { |
| 974 | 914 | continue; |
| 975 | 915 | } |
| 976 | 916 | Content::Ink(ink) => { |
| 977 | let [x, y] = crate::editor::page::ink_offset(ink); | |
| 917 | let [x, y] = crate::origin(&ink.layout); | |
| 978 | 918 | let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id)); |
| 979 | 919 | let offset = [offset[0] + x + dx, offset[1] + y + dy]; |
| 980 | 920 | append_ink(ink, offset, paper.ink, primitives); |
| ... | ... | @@ -984,7 +924,7 @@ impl PageScene { |
| 984 | 924 | // A dragged file draws where its column would land. |
| 985 | 925 | let [x, y] = match moving { |
| 986 | 926 | Some((id, [x0, y0, ..])) if id == source.id => [x0, y0], |
| 987 | _ => [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)), | |
| 927 | _ => crate::origin(&source.layout), | |
| 988 | 928 | }; |
| 989 | 929 | self.append_object( |
| 990 | 930 | layout, |
| ... | ... | @@ -1043,12 +983,7 @@ impl PageScene { |
| 1043 | 983 | continue; |
| 1044 | 984 | }; |
| 1045 | 985 | let rect = match moving { |
| 1046 | Some((id, [x0, y0, x1, y1])) if id == source.id => [ | |
| 1047 | x0 + offset[0], | |
| 1048 | y0 + offset[1], | |
| 1049 | x1 + offset[0], | |
| 1050 | y1 + offset[1], | |
| 1051 | ], | |
| 986 | Some((id, rect)) if id == source.id => crate::translated(rect, offset), | |
| 1052 | 987 | _ => [ |
| 1053 | 988 | object_origin[0], |
| 1054 | 989 | object_origin[1], |
| ... | ... | @@ -1120,7 +1055,7 @@ impl PageScene { |
| 1120 | 1055 | |
| 1121 | 1056 | impl crate::outline::OutlineLayout { |
| 1122 | 1057 | /// Paragraphs that may paint between outline-local `rows`, with their indices. |
| 1123 | pub fn visible( | |
| 1058 | pub(crate) fn visible( | |
| 1124 | 1059 | &self, |
| 1125 | 1060 | rows: [f32; 2], |
| 1126 | 1061 | ) -> impl Iterator<Item = (usize, &crate::outline::ParagraphLayout)> { |
| ... | ... | @@ -1141,7 +1076,7 @@ impl crate::outline::OutlineLayout { |
| 1141 | 1076 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black |
| 1142 | 1077 | /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. |
| 1143 | 1078 | /// A paragraph's band spans its outline, or its cell, tinted for the paper. |
| 1144 | pub fn append_background_primitives( | |
| 1079 | pub(crate) fn append_background_primitives( | |
| 1145 | 1080 | &self, |
| 1146 | 1081 | primitives: &mut Vec<Primitive<'_>>, |
| 1147 | 1082 | origin: [f32; 2], |
| ... | ... | @@ -1200,7 +1135,7 @@ impl crate::outline::OutlineLayout { |
| 1200 | 1135 | |
| 1201 | 1136 | /// One paragraph of this outline, whose origin is `origin`: its text or equation, list |
| 1202 | 1137 | /// markers and tags, those `art` maps drawn with their art. |
| 1203 | pub fn append_paragraph_primitives<'a>( | |
| 1138 | pub(crate) fn append_paragraph_primitives<'a>( | |
| 1204 | 1139 | &'a self, |
| 1205 | 1140 | index: usize, |
| 1206 | 1141 | paragraph: &'a crate::outline::ParagraphLayout, |
| ... | ... | @@ -1291,7 +1226,7 @@ impl crate::outline::OutlineLayout { |
| 1291 | 1226 | |
| 1292 | 1227 | /// The note tags of this outline's tables, pictures and files, whose origin is `origin`, |
| 1293 | 1228 | /// those `art` maps drawn with their art. |
| 1294 | pub fn append_block_tag_primitives( | |
| 1229 | pub(crate) fn append_block_tag_primitives( | |
| 1295 | 1230 | &self, |
| 1296 | 1231 | primitives: &mut Vec<Primitive<'_>>, |
| 1297 | 1232 | origin: [f32; 2], |
| ... | ... | @@ -1316,7 +1251,7 @@ impl crate::outline::OutlineLayout { |
| 1316 | 1251 | } |
| 1317 | 1252 | } |
| 1318 | 1253 | |
| 1319 | pub fn append_table_primitives( | |
| 1254 | pub(crate) fn append_table_primitives( | |
| 1320 | 1255 | &self, |
| 1321 | 1256 | primitives: &mut Vec<Primitive<'_>>, |
| 1322 | 1257 | origin: [f32; 2], |
| ... | ... | @@ -1409,6 +1344,7 @@ mod tests { |
| 1409 | 1344 | created: Some(1), |
| 1410 | 1345 | title: "Header".into(), |
| 1411 | 1346 | margin_origin: [36.0, 14.4], |
| 1347 | rtl: false, | |
| 1412 | 1348 | color: None, |
| 1413 | 1349 | rule_lines: None, |
| 1414 | 1350 | definitions: BTreeMap::new(), |
| ... | ... | @@ -1858,6 +1794,7 @@ mod tests { |
| 1858 | 1794 | created: None, |
| 1859 | 1795 | title: "Header".into(), |
| 1860 | 1796 | margin_origin: [36.0, 14.4], |
| 1797 | rtl: false, | |
| 1861 | 1798 | color: None, |
| 1862 | 1799 | rule_lines: None, |
| 1863 | 1800 | definitions: BTreeMap::new(), |
| ... | ... | @@ -1934,6 +1871,7 @@ mod tests { |
| 1934 | 1871 | created: None, |
| 1935 | 1872 | title: String::new(), |
| 1936 | 1873 | margin_origin: [0.0; 2], |
| 1874 | rtl: false, | |
| 1937 | 1875 | color: None, |
| 1938 | 1876 | rule_lines: None, |
| 1939 | 1877 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2020,6 +1958,7 @@ mod tests { |
| 2020 | 1958 | created: None, |
| 2021 | 1959 | title: String::new(), |
| 2022 | 1960 | margin_origin: [36.0, 14.0], |
| 1961 | rtl: false, | |
| 2023 | 1962 | color: None, |
| 2024 | 1963 | rule_lines: None, |
| 2025 | 1964 | objects: vec![unsupported, PageObject::Outline(editable)], |
| ... | ... | @@ -2108,6 +2047,7 @@ mod tests { |
| 2108 | 2047 | created: None, |
| 2109 | 2048 | title: String::new(), |
| 2110 | 2049 | margin_origin: [0.0; 2], |
| 2050 | rtl: false, | |
| 2111 | 2051 | color: None, |
| 2112 | 2052 | rule_lines: None, |
| 2113 | 2053 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2294,6 +2234,7 @@ mod tests { |
| 2294 | 2234 | created: None, |
| 2295 | 2235 | title: String::new(), |
| 2296 | 2236 | margin_origin: [0.0; 2], |
| 2237 | rtl: false, | |
| 2297 | 2238 | color: None, |
| 2298 | 2239 | rule_lines: None, |
| 2299 | 2240 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2478,6 +2419,7 @@ mod tests { |
| 2478 | 2419 | created: None, |
| 2479 | 2420 | title: String::new(), |
| 2480 | 2421 | margin_origin: [0.0; 2], |
| 2422 | rtl: false, | |
| 2481 | 2423 | color: None, |
| 2482 | 2424 | rule_lines: None, |
| 2483 | 2425 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2592,6 +2534,7 @@ mod tests { |
| 2592 | 2534 | created: None, |
| 2593 | 2535 | title: String::new(), |
| 2594 | 2536 | margin_origin: [0.0; 2], |
| 2537 | rtl: false, | |
| 2595 | 2538 | color: None, |
| 2596 | 2539 | rule_lines: None, |
| 2597 | 2540 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2689,6 +2632,7 @@ mod tests { |
| 2689 | 2632 | created: None, |
| 2690 | 2633 | title: String::new(), |
| 2691 | 2634 | margin_origin: [0.0; 2], |
| 2635 | rtl: false, | |
| 2692 | 2636 | color: None, |
| 2693 | 2637 | rule_lines: None, |
| 2694 | 2638 | definitions: BTreeMap::new(), |
| ... | ... | @@ -2763,6 +2707,7 @@ mod tests { |
| 2763 | 2707 | created: None, |
| 2764 | 2708 | title: String::new(), |
| 2765 | 2709 | margin_origin: [0.0; 2], |
| 2710 | rtl: false, | |
| 2766 | 2711 | color: None, |
| 2767 | 2712 | rule_lines: None, |
| 2768 | 2713 | definitions: BTreeMap::new(), |
crates/canvas/src/gpu/page/background.rs+8-7| ... | ... | @@ -410,11 +410,11 @@ const INLINE_PIXELS: f32 = 1024.0 * 1024.0; |
| 410 | 410 | pub(super) struct Background { |
| 411 | 411 | svg: &'static str, |
| 412 | 412 | /// Points; OneNote draws a picture without a stored size at its natural size. |
| 413 | pub size: [f32; 2], | |
| 413 | pub(crate) size: [f32; 2], | |
| 414 | 414 | /// For light and for dark paper. |
| 415 | 415 | variants: [Variant; 2], |
| 416 | 416 | /// Whether the first raster is made on the frame's thread rather than awaited. |
| 417 | pub inline: bool, | |
| 417 | pub(crate) inline: bool, | |
| 418 | 418 | } |
| 419 | 419 | |
| 420 | 420 | /// A raster's device pixels per point, and the dark paper it was recoloured for. |
| ... | ... | @@ -430,19 +430,19 @@ struct Variant { |
| 430 | 430 | |
| 431 | 431 | impl Background { |
| 432 | 432 | /// The rasters made so far, for light and for dark paper. |
| 433 | pub fn images(&self) -> impl Iterator<Item = &RasterImage> { | |
| 433 | pub(crate) fn images(&self) -> impl Iterator<Item = &RasterImage> { | |
| 434 | 434 | self.variants |
| 435 | 435 | .iter() |
| 436 | 436 | .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) |
| 437 | 437 | } |
| 438 | 438 | |
| 439 | 439 | /// Whether both recreate the same template picture at the same size. |
| 440 | pub fn same(&self, other: &Self) -> bool { | |
| 440 | pub(crate) fn same(&self, other: &Self) -> bool { | |
| 441 | 441 | self.svg == other.svg && self.size == other.size |
| 442 | 442 | } |
| 443 | 443 | |
| 444 | 444 | /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker. |
| 445 | pub fn recognise(picture: &onestore::page::Image) -> Option<Self> { | |
| 445 | pub(crate) fn recognise(picture: &onestore::page::Image) -> Option<Self> { | |
| 446 | 446 | let bytes = picture.bytes.as_deref()?; |
| 447 | 447 | let digest: String = Sha256::digest(bytes) |
| 448 | 448 | .iter() |
| ... | ... | @@ -472,7 +472,7 @@ impl Background { |
| 472 | 472 | } |
| 473 | 473 | |
| 474 | 474 | /// The latest raster for this paper, if one has landed. |
| 475 | pub fn image(&self, paper: Paper) -> Option<&RasterImage> { | |
| 475 | pub(crate) fn image(&self, paper: Paper) -> Option<&RasterImage> { | |
| 476 | 476 | let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?; |
| 477 | 477 | Some(image) |
| 478 | 478 | } |
| ... | ... | @@ -480,7 +480,7 @@ impl Background { |
| 480 | 480 | /// Takes a finished raster and asks the worker for one at `scale` device pixels per |
| 481 | 481 | /// point when the shown one differs; `waker` hears when it lands. True once the raster |
| 482 | 482 | /// shown matches. |
| 483 | pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { | |
| 483 | pub(crate) fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { | |
| 484 | 484 | let dark = dark(paper); |
| 485 | 485 | let [width, height] = self.size.map(|side| side.max(1.0)); |
| 486 | 486 | let density = density(scale) |
| ... | ... | @@ -958,6 +958,7 @@ mod tests { |
| 958 | 958 | created: None, |
| 959 | 959 | title: String::new(), |
| 960 | 960 | margin_origin: [0.0; 2], |
| 961 | rtl: false, | |
| 961 | 962 | color: None, |
| 962 | 963 | rule_lines: None, |
| 963 | 964 | definitions: BTreeMap::new(), |
crates/canvas/src/gpu/page/picture.rs+4-4| ... | ... | @@ -30,12 +30,12 @@ pub(super) struct Picture { |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | impl Picture { |
| 33 | /// Reads the size from the picture's header; None when the renderer cannot decode it. | |
| 34 | 33 | /// Whether both show the same stored bytes. |
| 35 | pub fn same(&self, other: &Self) -> bool { | |
| 34 | pub(crate) fn same(&self, other: &Self) -> bool { | |
| 36 | 35 | self.bytes == other.bytes |
| 37 | 36 | } |
| 38 | 37 | |
| 38 | /// Reads the size from the picture's header; None when the renderer cannot decode it. | |
| 39 | 39 | pub fn new(bytes: &Arc<[u8]>) -> Option<Self> { |
| 40 | 40 | Some(Self { |
| 41 | 41 | native: RasterImage::measure(bytes).ok()?, |
| ... | ... | @@ -48,11 +48,11 @@ impl Picture { |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | /// The latest raster, once one has landed. |
| 51 | pub fn image(&self) -> Option<&RasterImage> { | |
| 51 | pub(crate) fn image(&self) -> Option<&RasterImage> { | |
| 52 | 52 | self.shown.as_ref() |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | pub fn failed(&self) -> bool { | |
| 55 | pub(crate) fn failed(&self) -> bool { | |
| 56 | 56 | self.failed |
| 57 | 57 | } |
| 58 | 58 | } |
crates/canvas/src/interaction/accessibility.rs+2| ... | ... | @@ -722,6 +722,7 @@ pub(super) mod tests { |
| 722 | 722 | title: "Header".into(), |
| 723 | 723 | created: None, |
| 724 | 724 | margin_origin: [36.0, 14.4], |
| 725 | rtl: false, | |
| 725 | 726 | color: None, |
| 726 | 727 | rule_lines: None, |
| 727 | 728 | definitions: Default::default(), |
| ... | ... | @@ -816,6 +817,7 @@ pub(super) mod tests { |
| 816 | 817 | created: None, |
| 817 | 818 | title: String::new(), |
| 818 | 819 | margin_origin: [0.0; 2], |
| 820 | rtl: false, | |
| 819 | 821 | color: None, |
| 820 | 822 | rule_lines: None, |
| 821 | 823 | definitions: Default::default(), |
crates/canvas/src/interaction/mod.rs+48-80| ... | ... | @@ -35,7 +35,7 @@ const HANDLE_HEIGHT: f32 = 6.75; |
| 35 | 35 | /// Accessible names of the page date's fields. |
| 36 | 36 | pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; |
| 37 | 37 | |
| 38 | pub type Result<T> = std::result::Result<T, Box<dyn Error>>; | |
| 38 | pub(crate) type Result<T> = std::result::Result<T, Box<dyn Error>>; | |
| 39 | 39 | |
| 40 | 40 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 41 | 41 | pub enum Cursor { |
| ... | ... | @@ -112,7 +112,7 @@ pub struct Correction { |
| 112 | 112 | pub struct Response { |
| 113 | 113 | pub changed: bool, |
| 114 | 114 | pub moved: bool, |
| 115 | pub redraw: bool, | |
| 115 | pub(crate) redraw: bool, | |
| 116 | 116 | pub request: Option<Request>, |
| 117 | 117 | } |
| 118 | 118 | |
| ... | ... | @@ -359,7 +359,7 @@ impl PageView { |
| 359 | 359 | } |
| 360 | 360 | |
| 361 | 361 | /// Shows a page reloaded from storage in place of the edited one, whose editor it returns. |
| 362 | pub fn replace( | |
| 362 | pub(crate) fn replace( | |
| 363 | 363 | &mut self, |
| 364 | 364 | mut editor: CanvasEditor, |
| 365 | 365 | scene: Option<(PageScene, [f32; 2])>, |
| ... | ... | @@ -435,10 +435,16 @@ impl PageView { |
| 435 | 435 | } |
| 436 | 436 | } |
| 437 | 437 | |
| 438 | /// OneNote 2010 opens a page scrolled fully left and up, however far down its content | |
| 439 | /// lies. | |
| 438 | /// OneNote 2010 opens a page scrolled fully up, however far down its content lies, and | |
| 439 | /// fully left, or right on a right-to-left page. | |
| 440 | 440 | fn place_opened(&mut self) { |
| 441 | self.viewport.origin = self.scroll().min.map(|offset| -offset); | |
| 441 | let scroll = self.scroll(); | |
| 442 | let x = if self.editor.rtl() { | |
| 443 | scroll.max[0] | |
| 444 | } else { | |
| 445 | scroll.min[0] | |
| 446 | }; | |
| 447 | self.viewport.origin = [-x, -scroll.min[1]]; | |
| 442 | 448 | } |
| 443 | 449 | |
| 444 | 450 | pub fn modifiers(&self) -> Modifiers { |
| ... | ... | @@ -480,6 +486,12 @@ impl PageView { |
| 480 | 486 | self.object_focus.is_none() |
| 481 | 487 | } |
| 482 | 488 | |
| 489 | /// Edits from the toolbar and menus wait while an input method composes or an object holds | |
| 490 | /// focus. | |
| 491 | fn edits_wait(&self) -> bool { | |
| 492 | !self.accepts_text() || self.editor.marked_range().is_some() | |
| 493 | } | |
| 494 | ||
| 483 | 495 | /// The outline being dragged and where it would land. |
| 484 | 496 | pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { |
| 485 | 497 | let Drag::Outline { |
| ... | ... | @@ -624,7 +636,7 @@ impl PageView { |
| 624 | 636 | /// Page coordinates of a focused object. |
| 625 | 637 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { |
| 626 | 638 | let (scene, offset) = self.scene.as_ref().unwrap(); |
| 627 | let [x0, y0, x1, y1] = match focus { | |
| 639 | let rect = match focus { | |
| 628 | 640 | ObjectFocus::ReadOnly(index) => scene |
| 629 | 641 | .read_only(Some(&self.editor)) |
| 630 | 642 | .nth(index) |
| ... | ... | @@ -636,12 +648,7 @@ impl PageView { |
| 636 | 648 | } |
| 637 | 649 | ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), |
| 638 | 650 | }; |
| 639 | [ | |
| 640 | x0 + offset[0], | |
| 641 | y0 + offset[1], | |
| 642 | x1 + offset[0], | |
| 643 | y1 + offset[1], | |
| 644 | ] | |
| 651 | crate::translated(rect, *offset) | |
| 645 | 652 | } |
| 646 | 653 | |
| 647 | 654 | /// Keeps the view in bounds and restarts the caret blink after a change. |
| ... | ... | @@ -778,10 +785,7 @@ impl PageView { |
| 778 | 785 | return self.moved(); |
| 779 | 786 | } |
| 780 | 787 | } |
| 781 | Ok(Response { | |
| 782 | redraw: true, | |
| 783 | ..Response::default() | |
| 784 | }) | |
| 788 | Ok(Response::redraw()) | |
| 785 | 789 | } |
| 786 | 790 | |
| 787 | 791 | /// Moves the caret by lines until it has gone a view's height, then scrolls as far so |
| ... | ... | @@ -881,18 +885,16 @@ impl PageView { |
| 881 | 885 | .content_bounds(&self.editor) |
| 882 | 886 | .map(move |(rect, padded)| { |
| 883 | 887 | ( |
| 884 | [ | |
| 885 | rect[0] + offset[0], | |
| 886 | rect[1] + offset[1], | |
| 887 | rect[2] + offset[0], | |
| 888 | rect[3] + offset[1], | |
| 889 | ], | |
| 888 | crate::translated(rect, *offset), | |
| 890 | 889 | if padded { pad } else { 0.0 }, |
| 891 | 890 | ) |
| 892 | 891 | }) |
| 893 | 892 | }); |
| 894 | let mut scroll = | |
| 895 | scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)); | |
| 893 | let mut scroll = scroll::Scroll::new( | |
| 894 | self.viewport, | |
| 895 | editable.map(|rect| (rect, pad)).chain(fixed), | |
| 896 | self.editor.rtl(), | |
| 897 | ); | |
| 896 | 898 | if let Some(reach) = self.reach { |
| 897 | 899 | for axis in 0..2 { |
| 898 | 900 | scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); |
| ... | ... | @@ -989,17 +991,7 @@ impl PageView { |
| 989 | 991 | preview, |
| 990 | 992 | self.object_focus, |
| 991 | 993 | Paint { |
| 992 | caret: if self.focused | |
| 993 | && !matches!( | |
| 994 | self.drag, | |
| 995 | Some( | |
| 996 | Drag::Outline { .. } | |
| 997 | | Drag::Resize { .. } | |
| 998 | | Drag::Column { .. } | |
| 999 | | Drag::Image { .. } | |
| 1000 | | Drag::Space { .. } | |
| 1001 | ) | |
| 1002 | ) { | |
| 994 | caret: if self.focused && matches!(self.drag, None | Some(Drag::Text { .. })) { | |
| 1003 | 995 | self.caret |
| 1004 | 996 | } else { |
| 1005 | 997 | 0.0 |
| ... | ... | @@ -1069,7 +1061,6 @@ impl PageView { |
| 1069 | 1061 | if self.drawing() { |
| 1070 | 1062 | return Cursor::Crosshair; |
| 1071 | 1063 | } |
| 1072 | let point = self.viewport.document_point(self.pointer); | |
| 1073 | 1064 | if self.drag.is_none() && self.play_button(point).is_some() { |
| 1074 | 1065 | return Cursor::Pointer; |
| 1075 | 1066 | } |
| ... | ... | @@ -1111,8 +1102,12 @@ impl PageView { |
| 1111 | 1102 | (repaint, hold.map(|hold| now + hold)) |
| 1112 | 1103 | } |
| 1113 | 1104 | |
| 1114 | /// `size` in device pixels. | |
| 1105 | /// `size` in device pixels. A right-to-left page keeps its right edge in place, as | |
| 1106 | /// OneNote 2010's does. | |
| 1115 | 1107 | pub fn resized(&mut self, size: [u32; 2]) -> Result<Response> { |
| 1108 | if self.editor.rtl() { | |
| 1109 | self.viewport.origin[0] += size[0] as f32 - self.viewport.size[0] as f32; | |
| 1110 | } | |
| 1116 | 1111 | self.viewport.size = size; |
| 1117 | 1112 | if size.contains(&0) { |
| 1118 | 1113 | return Ok(Response::default()); |
| ... | ... | @@ -1534,7 +1529,7 @@ impl PageView { |
| 1534 | 1529 | { |
| 1535 | 1530 | return self.edited(); |
| 1536 | 1531 | } |
| 1537 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1532 | if self.edits_wait() { | |
| 1538 | 1533 | return Ok(Response::default()); |
| 1539 | 1534 | } |
| 1540 | 1535 | self.editor.format(&mut self.engine, command)?; |
| ... | ... | @@ -1783,7 +1778,7 @@ impl PageView { |
| 1783 | 1778 | |
| 1784 | 1779 | /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`]. |
| 1785 | 1780 | pub fn insert_equation(&mut self) -> Result<Response> { |
| 1786 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1781 | if self.edits_wait() { | |
| 1787 | 1782 | return Ok(Response::default()); |
| 1788 | 1783 | } |
| 1789 | 1784 | self.editor.insert_equation(&mut self.engine)?; |
| ... | ... | @@ -1792,7 +1787,7 @@ impl PageView { |
| 1792 | 1787 | |
| 1793 | 1788 | /// Insert, Table: see [`CanvasEditor::insert_table`]. |
| 1794 | 1789 | pub fn insert_table(&mut self, rows: usize, columns: usize) -> Result<Response> { |
| 1795 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1790 | if self.edits_wait() { | |
| 1796 | 1791 | return Ok(Response::default()); |
| 1797 | 1792 | } |
| 1798 | 1793 | self.editor.insert_table(&mut self.engine, rows, columns)?; |
| ... | ... | @@ -1802,7 +1797,7 @@ impl PageView { |
| 1802 | 1797 | /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see |
| 1803 | 1798 | /// [`CanvasEditor::insert_picture`]. |
| 1804 | 1799 | pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> { |
| 1805 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1800 | if self.edits_wait() { | |
| 1806 | 1801 | return Ok(Response::default()); |
| 1807 | 1802 | } |
| 1808 | 1803 | let image = crate::editor::picture(bytes, size)?; |
| ... | ... | @@ -1818,7 +1813,7 @@ impl PageView { |
| 1818 | 1813 | pieces: Vec<Piece>, |
| 1819 | 1814 | language: u32, |
| 1820 | 1815 | ) -> Result<(Vec<Awaited>, Response)> { |
| 1821 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1816 | if self.edits_wait() { | |
| 1822 | 1817 | return Ok((Vec::new(), Response::default())); |
| 1823 | 1818 | } |
| 1824 | 1819 | let awaited = self |
| ... | ... | @@ -1845,7 +1840,7 @@ impl PageView { |
| 1845 | 1840 | |
| 1846 | 1841 | /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. |
| 1847 | 1842 | pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { |
| 1848 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1843 | if self.edits_wait() { | |
| 1849 | 1844 | return Ok((None, Response::default())); |
| 1850 | 1845 | } |
| 1851 | 1846 | let id = self.editor.start_recording(&mut self.engine, label)?; |
| ... | ... | @@ -1879,7 +1874,7 @@ impl PageView { |
| 1879 | 1874 | /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon |
| 1880 | 1875 | /// takes the page's blank one. |
| 1881 | 1876 | pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> { |
| 1882 | if !self.accepts_text() || self.editor.marked_range().is_some() { | |
| 1877 | if self.edits_wait() { | |
| 1883 | 1878 | return Ok(Response::default()); |
| 1884 | 1879 | } |
| 1885 | 1880 | file.preview |
| ... | ... | @@ -2051,17 +2046,7 @@ impl PageView { |
| 2051 | 2046 | return self.edited(); |
| 2052 | 2047 | } |
| 2053 | 2048 | } |
| 2054 | if matches!( | |
| 2055 | self.drag, | |
| 2056 | Some( | |
| 2057 | Drag::Outline { .. } | |
| 2058 | | Drag::Resize { .. } | |
| 2059 | | Drag::Column { .. } | |
| 2060 | | Drag::Image { .. } | |
| 2061 | | Drag::Space { .. } | |
| 2062 | ) | |
| 2063 | ) || self.space | |
| 2064 | { | |
| 2049 | if !matches!(self.drag, None | Some(Drag::Text { .. })) || self.space { | |
| 2065 | 2050 | self.drag = None; |
| 2066 | 2051 | self.space = false; |
| 2067 | 2052 | if key == &Key::Named(NamedKey::Escape) { |
| ... | ... | @@ -2270,7 +2255,7 @@ pub enum Hit { |
| 2270 | 2255 | } |
| 2271 | 2256 | |
| 2272 | 2257 | /// What a document point lands on; `pixel` is document points per device pixel. |
| 2273 | pub fn page_hit_test( | |
| 2258 | pub(crate) fn page_hit_test( | |
| 2274 | 2259 | editor: &CanvasEditor, |
| 2275 | 2260 | scene: Option<&(PageScene, [f32; 2])>, |
| 2276 | 2261 | point: [f32; 2], |
| ... | ... | @@ -2574,16 +2559,7 @@ fn page_primitives<'a>( |
| 2574 | 2559 | if let Some((scene, _)) = scene { |
| 2575 | 2560 | let moving = match preview { |
| 2576 | 2561 | Some(PointerFeedback::Image(id, origin, size)) => { |
| 2577 | let [x0, y0, x1, y1] = image_rect(origin, size); | |
| 2578 | Some(( | |
| 2579 | id, | |
| 2580 | [ | |
| 2581 | x0 + offset[0], | |
| 2582 | y0 + offset[1], | |
| 2583 | x1 + offset[0], | |
| 2584 | y1 + offset[1], | |
| 2585 | ], | |
| 2586 | )) | |
| 2562 | Some((id, crate::translated(image_rect(origin, size), offset))) | |
| 2587 | 2563 | } |
| 2588 | 2564 | _ => None, |
| 2589 | 2565 | }; |
| ... | ... | @@ -2663,13 +2639,10 @@ fn page_primitives<'a>( |
| 2663 | 2639 | } |
| 2664 | 2640 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { |
| 2665 | 2641 | let (scene, offset) = scene.unwrap(); |
| 2666 | let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); | |
| 2667 | let [x0, y0, x1, y1] = [ | |
| 2668 | x0 + offset[0], | |
| 2669 | y0 + offset[1], | |
| 2670 | x1 + offset[0], | |
| 2671 | y1 + offset[1], | |
| 2672 | ]; | |
| 2642 | let [x0, y0, x1, y1] = crate::translated( | |
| 2643 | scene.read_only(Some(editor)).nth(index).unwrap().rect(), | |
| 2644 | *offset, | |
| 2645 | ); | |
| 2673 | 2646 | let border = 2.0 / paint.scale; |
| 2674 | 2647 | for rect in [ |
| 2675 | 2648 | [x0, y0, x1, y0 + border], |
| ... | ... | @@ -2899,12 +2872,7 @@ fn append_outline_chrome( |
| 2899 | 2872 | let [x, y] = origin; |
| 2900 | 2873 | let (bounds, body_top) = outline_chrome(outline, pixel); |
| 2901 | 2874 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; |
| 2902 | let [left, top, right, bottom] = [ | |
| 2903 | bounds[0] + dx, | |
| 2904 | bounds[1] + dy, | |
| 2905 | bounds[2] + dx, | |
| 2906 | bounds[3] + dy, | |
| 2907 | ]; | |
| 2875 | let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]); | |
| 2908 | 2876 | let body_top = body_top + dy; |
| 2909 | 2877 | if outline.title { |
| 2910 | 2878 | primitives.push(Primitive::RoundedRect { |
crates/canvas/src/interaction/scroll.rs+35-7| ... | ... | @@ -10,25 +10,36 @@ pub struct Scroll { |
| 10 | 10 | |
| 11 | 11 | impl Scroll { |
| 12 | 12 | /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond |
| 13 | /// it left and up. | |
| 14 | pub fn new(viewport: Viewport, bounds: impl Iterator<Item = ([f32; 4], f32)>) -> Self { | |
| 13 | /// it up and toward the page's start: left, or right on a right-to-left page. | |
| 14 | pub fn new( | |
| 15 | viewport: Viewport, | |
| 16 | bounds: impl Iterator<Item = ([f32; 4], f32)>, | |
| 17 | rtl: bool, | |
| 18 | ) -> Self { | |
| 15 | 19 | let mut min = [0.0_f32; 2]; |
| 16 | 20 | let mut max = [0.0_f32; 2]; |
| 21 | let mut end = f32::NEG_INFINITY; | |
| 17 | 22 | for (rect, pad) in bounds { |
| 18 | 23 | for axis in 0..2 { |
| 19 | 24 | min[axis] = min[axis].min(rect[axis] * viewport.scale - pad); |
| 20 | 25 | max[axis] = max[axis].max(rect[axis + 2]); |
| 21 | 26 | } |
| 27 | end = end.max(rect[2] * viewport.scale + pad); | |
| 22 | 28 | } |
| 23 | // OneNote stops at the page origin unless content reaches past it. | |
| 29 | // OneNote stops at the page origin unless content reaches past it; a right-to-left | |
| 30 | // page ends where its content does. | |
| 24 | 31 | for axis in 0..2 { |
| 25 | max[axis] = | |
| 26 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); | |
| 32 | max[axis] = if rtl && axis == 0 && end.is_finite() { | |
| 33 | end - viewport.size[0] as f32 | |
| 34 | } else { | |
| 35 | (max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32 | |
| 36 | } | |
| 37 | .max(min[axis]); | |
| 27 | 38 | } |
| 28 | 39 | Self { min, max } |
| 29 | 40 | } |
| 30 | 41 | |
| 31 | pub fn clamp(&self, viewport: &mut Viewport) { | |
| 42 | pub(crate) fn clamp(&self, viewport: &mut Viewport) { | |
| 32 | 43 | for axis in 0..2 { |
| 33 | 44 | viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); |
| 34 | 45 | } |
| ... | ... | @@ -49,6 +60,7 @@ mod tests { |
| 49 | 60 | let scroll = Scroll::new( |
| 50 | 61 | viewport, |
| 51 | 62 | [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(), |
| 63 | false, | |
| 52 | 64 | ); |
| 53 | 65 | scroll.clamp(&mut viewport); |
| 54 | 66 | assert_eq!(viewport.origin, [171.0, 51.0]); |
| ... | ... | @@ -68,7 +80,23 @@ mod tests { |
| 68 | 80 | ([-0.5, -22.35, 613.0, 141.3], 0.0), |
| 69 | 81 | ([36.0, 86.4, 400.0, 180.0], 11.0), |
| 70 | 82 | ]; |
| 71 | let scroll = Scroll::new(viewport, bounds.into_iter()); | |
| 83 | let scroll = Scroll::new(viewport, bounds.into_iter(), false); | |
| 72 | 84 | assert_eq!(scroll.min, [-1.0, -44.7]); |
| 73 | 85 | } |
| 86 | ||
| 87 | #[test] | |
| 88 | fn right_to_left_pages_end_at_their_content() { | |
| 89 | let viewport = Viewport { | |
| 90 | size: [800, 600], | |
| 91 | scale: 2.0, | |
| 92 | origin: [0.0; 2], | |
| 93 | }; | |
| 94 | let bounds = [ | |
| 95 | ([10835.0, 14.4, 10962.0, 28.0], 11.0), | |
| 96 | ([11220.0, 84.0, 11334.0, 145.0], 0.0), | |
| 97 | ]; | |
| 98 | let scroll = Scroll::new(viewport, bounds.into_iter(), true); | |
| 99 | assert_eq!(scroll.min, [0.0, 0.0]); | |
| 100 | assert_eq!(scroll.max[0], 11334.0 * 2.0 - 800.0); | |
| 101 | } | |
| 74 | 102 | } |
crates/canvas/src/interaction/tests.rs+14| ... | ... | @@ -57,6 +57,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() |
| 57 | 57 | title: "Header".into(), |
| 58 | 58 | created: Some(1), |
| 59 | 59 | margin_origin: [36.0, 14.4], |
| 60 | rtl: false, | |
| 60 | 61 | color: None, |
| 61 | 62 | rule_lines: None, |
| 62 | 63 | definitions: Default::default(), |
| ... | ... | @@ -590,6 +591,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { |
| 590 | 591 | title: String::new(), |
| 591 | 592 | created: None, |
| 592 | 593 | margin_origin: [0.0; 2], |
| 594 | rtl: false, | |
| 593 | 595 | color: None, |
| 594 | 596 | rule_lines: None, |
| 595 | 597 | definitions: Default::default(), |
| ... | ... | @@ -719,6 +721,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { |
| 719 | 721 | identity: None, |
| 720 | 722 | created: None, |
| 721 | 723 | margin_origin: [36.0, 14.4], |
| 724 | rtl: false, | |
| 722 | 725 | color: None, |
| 723 | 726 | rule_lines: None, |
| 724 | 727 | definitions: Default::default(), |
| ... | ... | @@ -1247,6 +1250,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { |
| 1247 | 1250 | created: None, |
| 1248 | 1251 | title: String::new(), |
| 1249 | 1252 | margin_origin: [0.0; 2], |
| 1253 | rtl: false, | |
| 1250 | 1254 | color: None, |
| 1251 | 1255 | rule_lines: None, |
| 1252 | 1256 | definitions: Default::default(), |
| ... | ... | @@ -1445,6 +1449,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { |
| 1445 | 1449 | created: None, |
| 1446 | 1450 | title: String::new(), |
| 1447 | 1451 | margin_origin: [0.0; 2], |
| 1452 | rtl: false, | |
| 1448 | 1453 | color: None, |
| 1449 | 1454 | rule_lines: None, |
| 1450 | 1455 | definitions: Default::default(), |
| ... | ... | @@ -1566,6 +1571,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1566 | 1571 | identity: None, |
| 1567 | 1572 | created: None, |
| 1568 | 1573 | margin_origin: [36.0, 14.4], |
| 1574 | rtl: false, | |
| 1569 | 1575 | color: None, |
| 1570 | 1576 | rule_lines: None, |
| 1571 | 1577 | definitions: Default::default(), |
| ... | ... | @@ -1604,6 +1610,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { |
| 1604 | 1610 | identity: None, |
| 1605 | 1611 | created: None, |
| 1606 | 1612 | margin_origin: [36.0, 14.4], |
| 1613 | rtl: false, | |
| 1607 | 1614 | color: None, |
| 1608 | 1615 | rule_lines: None, |
| 1609 | 1616 | definitions: Default::default(), |
| ... | ... | @@ -2079,6 +2086,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { |
| 2079 | 2086 | identity: None, |
| 2080 | 2087 | created: None, |
| 2081 | 2088 | margin_origin: [36.0, 14.4], |
| 2089 | rtl: false, | |
| 2082 | 2090 | color: None, |
| 2083 | 2091 | rule_lines: None, |
| 2084 | 2092 | definitions: Default::default(), |
| ... | ... | @@ -2171,6 +2179,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { |
| 2171 | 2179 | identity: None, |
| 2172 | 2180 | created: None, |
| 2173 | 2181 | margin_origin: [36.0, 14.4], |
| 2182 | rtl: false, | |
| 2174 | 2183 | color: None, |
| 2175 | 2184 | rule_lines: None, |
| 2176 | 2185 | definitions: Default::default(), |
| ... | ... | @@ -2712,6 +2721,7 @@ fn art_draws_at_once_over_the_rule_lines_and_follows_undo() { |
| 2712 | 2721 | title: String::new(), |
| 2713 | 2722 | created: None, |
| 2714 | 2723 | margin_origin: [36.0, 14.4], |
| 2724 | rtl: false, | |
| 2715 | 2725 | color: None, |
| 2716 | 2726 | rule_lines: Some(crate::template::RULE_LINES[2].1), |
| 2717 | 2727 | definitions: Default::default(), |
| ... | ... | @@ -2832,6 +2842,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { |
| 2832 | 2842 | identity: None, |
| 2833 | 2843 | created: None, |
| 2834 | 2844 | margin_origin: [36.0, 14.4], |
| 2845 | rtl: false, | |
| 2835 | 2846 | color: None, |
| 2836 | 2847 | rule_lines: None, |
| 2837 | 2848 | definitions: Default::default(), |
| ... | ... | @@ -2959,6 +2970,7 @@ fn misspelled_words_are_marked_and_corrected_in_one_edit() { |
| 2959 | 2970 | identity: None, |
| 2960 | 2971 | created: None, |
| 2961 | 2972 | margin_origin: [36.0, 14.4], |
| 2973 | rtl: false, | |
| 2962 | 2974 | color: None, |
| 2963 | 2975 | rule_lines: None, |
| 2964 | 2976 | definitions: Default::default(), |
| ... | ... | @@ -3072,6 +3084,7 @@ fn the_note_playing_scrolls_into_view() { |
| 3072 | 3084 | identity: None, |
| 3073 | 3085 | created: None, |
| 3074 | 3086 | margin_origin: [36.0, 14.4], |
| 3087 | rtl: false, | |
| 3075 | 3088 | color: None, |
| 3076 | 3089 | rule_lines: None, |
| 3077 | 3090 | definitions: Default::default(), |
| ... | ... | @@ -3129,6 +3142,7 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { |
| 3129 | 3142 | identity: None, |
| 3130 | 3143 | created: None, |
| 3131 | 3144 | margin_origin: [36.0, 14.4], |
| 3145 | rtl: false, | |
| 3132 | 3146 | color: None, |
| 3133 | 3147 | rule_lines: None, |
| 3134 | 3148 | definitions: Default::default(), |
crates/canvas/src/language.rs+1-1| ... | ... | @@ -135,7 +135,7 @@ pub fn lcid(tag: &str) -> u32 { |
| 135 | 135 | |
| 136 | 136 | /// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks |
| 137 | 137 | /// for it. None for LCIDs outside the table, such as math's. |
| 138 | pub fn tag(lcid: u32) -> Option<&'static str> { | |
| 138 | pub(crate) fn tag(lcid: u32) -> Option<&'static str> { | |
| 139 | 139 | LCIDS |
| 140 | 140 | .iter() |
| 141 | 141 | .find(|(_, known)| *known == lcid) |
crates/canvas/src/layout.rs+15-15| ... | ... | @@ -42,11 +42,11 @@ const SUBSCRIPT_DROP: f32 = 0.08; |
| 42 | 42 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 43 | 43 | pub struct TextBrush { |
| 44 | 44 | /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. |
| 45 | pub color: Option<u32>, | |
| 46 | pub highlight: Option<u32>, | |
| 45 | pub(crate) color: Option<u32>, | |
| 46 | pub(crate) highlight: Option<u32>, | |
| 47 | 47 | /// Points the run draws above the line's baseline: positive for a superscript, |
| 48 | 48 | /// negative for a subscript. Line metrics and hit-testing keep the baseline. |
| 49 | pub rise: f32, | |
| 49 | pub(crate) rise: f32, | |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | #[derive(Clone, Debug)] |
| ... | ... | @@ -72,11 +72,11 @@ pub struct TextLayout { |
| 72 | 72 | /// Room kept in a line for something drawn inline, such as an equation, before the text at |
| 73 | 73 | /// byte `index`; the line grows to its ascent and descent. |
| 74 | 74 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 75 | pub struct InlineSpace { | |
| 76 | pub index: usize, | |
| 77 | pub width: f32, | |
| 78 | pub ascent: f32, | |
| 79 | pub descent: f32, | |
| 75 | pub(crate) struct InlineSpace { | |
| 76 | pub(crate) index: usize, | |
| 77 | pub(crate) width: f32, | |
| 78 | pub(crate) ascent: f32, | |
| 79 | pub(crate) descent: f32, | |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | ... | @@ -106,8 +106,8 @@ impl fmt::Display for LayoutError { |
| 106 | 106 | impl std::error::Error for LayoutError {} |
| 107 | 107 | |
| 108 | 108 | /// What a run without a font or size of its own is laid out in. |
| 109 | pub const DEFAULT_FONT: &str = "Arial"; | |
| 110 | pub const DEFAULT_FONT_SIZE: f32 = 11.0; | |
| 109 | pub(crate) const DEFAULT_FONT: &str = "Arial"; | |
| 110 | pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; | |
| 111 | 111 | |
| 112 | 112 | /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font |
| 113 | 113 | /// Licence files beside them, by the family each stands in for. |
| ... | ... | @@ -425,7 +425,7 @@ impl TextEngine { |
| 425 | 425 | } |
| 426 | 426 | |
| 427 | 427 | /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. |
| 428 | pub fn layout_with( | |
| 428 | pub(crate) fn layout_with( | |
| 429 | 429 | &mut self, |
| 430 | 430 | paragraph: &Paragraph, |
| 431 | 431 | width: f32, |
| ... | ... | @@ -531,7 +531,7 @@ impl TextEngine { |
| 531 | 531 | |
| 532 | 532 | impl TextLayout { |
| 533 | 533 | /// Process-unique identity for caches of an externally immutable layout. |
| 534 | pub fn id(&self) -> u64 { | |
| 534 | pub(crate) fn id(&self) -> u64 { | |
| 535 | 535 | self.id |
| 536 | 536 | } |
| 537 | 537 | |
| ... | ... | @@ -592,11 +592,11 @@ impl TextLayout { |
| 592 | 592 | last.top + last.height |
| 593 | 593 | } |
| 594 | 594 | |
| 595 | pub fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { | |
| 595 | pub(crate) fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { | |
| 596 | 596 | Cursor::from_byte_index(&self.shaped, byte, affinity) |
| 597 | 597 | } |
| 598 | 598 | |
| 599 | pub fn hit_test(&self, x: f32, y: f32) -> Cursor { | |
| 599 | pub(crate) fn hit_test(&self, x: f32, y: f32) -> Cursor { | |
| 600 | 600 | let index = self |
| 601 | 601 | .lines |
| 602 | 602 | .partition_point(|line| line.top + line.height <= y) |
| ... | ... | @@ -645,7 +645,7 @@ impl TextLayout { |
| 645 | 645 | |
| 646 | 646 | /// Each line's part of `selection` as a mark under the text runs: its left, its right |
| 647 | 647 | /// and the line's baseline. |
| 648 | pub fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> { | |
| 648 | pub(crate) fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> { | |
| 649 | 649 | selection |
| 650 | 650 | .geometry(&self.shaped) |
| 651 | 651 | .into_iter() |
crates/canvas/src/lib.rs+11| ... | ... | @@ -5,6 +5,7 @@ pub mod language; |
| 5 | 5 | pub mod layout; |
| 6 | 6 | pub mod math; |
| 7 | 7 | pub mod outline; |
| 8 | pub mod reading; | |
| 8 | 9 | pub mod search; |
| 9 | 10 | pub mod spelling; |
| 10 | 11 | pub mod template; |
| ... | ... | @@ -17,6 +18,16 @@ pub mod interaction; |
| 17 | 18 | #[cfg(feature = "pdf")] |
| 18 | 19 | pub mod print; |
| 19 | 20 | |
| 21 | /// Where an object stored with `layout` lies; an unset coordinate is zero. | |
| 22 | pub(crate) fn origin(layout: &onestore::document::Layout) -> [f32; 2] { | |
| 23 | [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)] | |
| 24 | } | |
| 25 | ||
| 26 | /// `[x0, y0, x1, y1]` moved by `[x, y]`. | |
| 27 | pub(crate) fn translated([x0, y0, x1, y1]: [f32; 4], [x, y]: [f32; 2]) -> [f32; 4] { | |
| 28 | [x0 + x, y0 + y, x1 + x, y1 + y] | |
| 29 | } | |
| 30 | ||
| 20 | 31 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. |
| 21 | 32 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { |
| 22 | 33 | match affinity { |
crates/canvas/src/math.rs+4-4| ... | ... | @@ -11,14 +11,14 @@ use onestore::page::text::Paragraph; |
| 11 | 11 | pub struct MathLayout { |
| 12 | 12 | pub size: [f32; 2], |
| 13 | 13 | /// COLORREF for rules and strokes, or `None` for the paper's ink. |
| 14 | pub color: Option<u32>, | |
| 14 | pub(crate) color: Option<u32>, | |
| 15 | 15 | /// Distance from the top to the baseline of the equation's main row. |
| 16 | 16 | pub baseline: f32, |
| 17 | pub items: Vec<MathItem>, | |
| 17 | pub(crate) items: Vec<MathItem>, | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | #[derive(Clone)] |
| 21 | pub enum MathItem { | |
| 21 | pub(crate) enum MathItem { | |
| 22 | 22 | Text { |
| 23 | 23 | layout: TextLayout, |
| 24 | 24 | origin: [f32; 2], |
| ... | ... | @@ -79,7 +79,7 @@ fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem { |
| 79 | 79 | layout, |
| 80 | 80 | origin: [origin[0] + dx, origin[1] + dy], |
| 81 | 81 | }, |
| 82 | MathItem::Rule([x0, y0, x1, y1]) => MathItem::Rule([x0 + dx, y0 + dy, x1 + dx, y1 + dy]), | |
| 82 | MathItem::Rule(rect) => MathItem::Rule(crate::translated(rect, [dx, dy])), | |
| 83 | 83 | MathItem::Stroke { from, to, width } => MathItem::Stroke { |
| 84 | 84 | from: [from[0] + dx, from[1] + dy], |
| 85 | 85 | to: [to[0] + dx, to[1] + dy], |
crates/canvas/src/outline.rs+26-26| ... | ... | @@ -21,7 +21,7 @@ pub struct OutlineLayout { |
| 21 | 21 | pub paragraphs: Vec<ParagraphLayout>, |
| 22 | 22 | pub tables: Vec<TableLayout>, |
| 23 | 23 | /// Pictures, files and handwriting that occupy a paragraph of their own. |
| 24 | pub objects: Vec<ObjectLayout>, | |
| 24 | pub(crate) objects: Vec<ObjectLayout>, | |
| 25 | 25 | pub size: [f32; 2], |
| 26 | 26 | /// One per root node, so an edit places anew only what it changes and moves what follows. |
| 27 | 27 | blocks: Vec<Block>, |
| ... | ... | @@ -49,37 +49,37 @@ struct Block { |
| 49 | 49 | |
| 50 | 50 | /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. |
| 51 | 51 | pub(crate) struct Relayout { |
| 52 | pub range: Range<usize>, | |
| 53 | pub segment: OutlineLayout, | |
| 52 | pub(crate) range: Range<usize>, | |
| 53 | pub(crate) segment: OutlineLayout, | |
| 54 | 54 | /// Each following node's new top and flow state, up to the first that stays. |
| 55 | 55 | moved: Vec<(f32, (f64, Option<f32>))>, |
| 56 | pub size: [f32; 2], | |
| 56 | pub(crate) size: [f32; 2], | |
| 57 | 57 | widths: [f32; 2], |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | 60 | #[derive(Clone)] |
| 61 | pub struct ObjectLayout { | |
| 61 | pub(crate) struct ObjectLayout { | |
| 62 | 62 | /// The object's identity, which keys a picture's or file icon's decoded image. |
| 63 | pub id: ExGuid, | |
| 63 | pub(crate) id: ExGuid, | |
| 64 | 64 | /// Where the picture, file icon, drawing or placeholder draws, outline-local. |
| 65 | pub rect: [f32; 4], | |
| 66 | pub kind: ObjectKind, | |
| 65 | pub(crate) rect: [f32; 4], | |
| 66 | pub(crate) kind: ObjectKind, | |
| 67 | 67 | /// Outline-local bottom of the whole object, label included. |
| 68 | pub bottom: f32, | |
| 69 | pub tags: Option<BlockTags>, | |
| 68 | pub(crate) bottom: f32, | |
| 69 | pub(crate) tags: Option<BlockTags>, | |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | 72 | /// The note tags of a table, picture or file, which OneNote centres on it in the tag column. |
| 73 | 73 | #[derive(Clone)] |
| 74 | pub struct BlockTags { | |
| 74 | pub(crate) struct BlockTags { | |
| 75 | 75 | /// The paragraph holding the block. |
| 76 | pub paragraph: ExGuid, | |
| 76 | pub(crate) paragraph: ExGuid, | |
| 77 | 77 | /// Its siblings' group, whose list markers the tags clear. |
| 78 | 78 | parent: Option<ExGuid>, |
| 79 | 79 | /// The block's left edge, outline-local. |
| 80 | 80 | x: f32, |
| 81 | 81 | /// Origins are outline-local. |
| 82 | pub tags: Vec<ParagraphTag>, | |
| 82 | pub(crate) tags: Vec<ParagraphTag>, | |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | 85 | impl BlockTags { |
| ... | ... | @@ -130,7 +130,7 @@ impl BlockTags { |
| 130 | 130 | } |
| 131 | 131 | |
| 132 | 132 | #[derive(Clone)] |
| 133 | pub enum ObjectKind { | |
| 133 | pub(crate) enum ObjectKind { | |
| 134 | 134 | Picture, |
| 135 | 135 | /// A file's icon with its name centered below. |
| 136 | 136 | File(ParagraphLayout), |
| ... | ... | @@ -141,7 +141,7 @@ pub enum ObjectKind { |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | 143 | impl ObjectLayout { |
| 144 | pub fn label(&self) -> Option<&ParagraphLayout> { | |
| 144 | pub(crate) fn label(&self) -> Option<&ParagraphLayout> { | |
| 145 | 145 | match &self.kind { |
| 146 | 146 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), |
| 147 | 147 | ObjectKind::Picture | ObjectKind::Ink(_) => None, |
| ... | ... | @@ -243,7 +243,7 @@ pub struct TableLayout { |
| 243 | 243 | /// Cells are in paragraph order with disjoint visible ranges. |
| 244 | 244 | pub cells: Vec<CellLayout>, |
| 245 | 245 | pub borders: bool, |
| 246 | pub tags: Option<BlockTags>, | |
| 246 | pub(crate) tags: Option<BlockTags>, | |
| 247 | 247 | } |
| 248 | 248 | |
| 249 | 249 | #[derive(Clone)] |
| ... | ... | @@ -251,12 +251,12 @@ pub struct CellLayout { |
| 251 | 251 | pub id: ExGuid, |
| 252 | 252 | pub rect: [f32; 4], |
| 253 | 253 | /// Visible paragraph indices, including paragraphs in nested tables. |
| 254 | pub paragraphs: std::ops::Range<usize>, | |
| 254 | pub(crate) paragraphs: std::ops::Range<usize>, | |
| 255 | 255 | } |
| 256 | 256 | |
| 257 | 257 | impl CellLayout { |
| 258 | 258 | /// Native ink gutters extend beyond the cell borders. |
| 259 | pub fn text_bounds(&self) -> [f32; 4] { | |
| 259 | pub(crate) fn text_bounds(&self) -> [f32; 4] { | |
| 260 | 260 | [ |
| 261 | 261 | self.rect[0] - 2.7, |
| 262 | 262 | self.rect[1], |
| ... | ... | @@ -265,7 +265,7 @@ impl CellLayout { |
| 265 | 265 | ] |
| 266 | 266 | } |
| 267 | 267 | |
| 268 | pub fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> { | |
| 268 | pub(crate) fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> { | |
| 269 | 269 | let [left, top, right, bottom] = self.text_bounds().map(f64::from); |
| 270 | 270 | let rect = parley::BoundingBox::new( |
| 271 | 271 | rect.x0.max(left), |
| ... | ... | @@ -296,7 +296,7 @@ pub struct ParagraphLayout { |
| 296 | 296 | pub math: Vec<crate::math::MathLayout>, |
| 297 | 297 | /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its |
| 298 | 298 | /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. |
| 299 | pub band: Option<u32>, | |
| 299 | pub(crate) band: Option<u32>, | |
| 300 | 300 | } |
| 301 | 301 | |
| 302 | 302 | /// How the page draws a note tag. |
| ... | ... | @@ -318,13 +318,13 @@ impl TagIcon { |
| 318 | 318 | }) |
| 319 | 319 | } |
| 320 | 320 | |
| 321 | pub fn checkable(self) -> bool { | |
| 321 | pub(crate) fn checkable(self) -> bool { | |
| 322 | 322 | matches!(self, Self::Symbol { shape, .. } if checkable(shape)) |
| 323 | 323 | } |
| 324 | 324 | } |
| 325 | 325 | |
| 326 | 326 | /// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. |
| 327 | pub fn checkable(shape: u16) -> bool { | |
| 327 | pub(crate) fn checkable(shape: u16) -> bool { | |
| 328 | 328 | matches!( |
| 329 | 329 | shape, |
| 330 | 330 | 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 |
| ... | ... | @@ -498,9 +498,9 @@ pub struct ParagraphTag { |
| 498 | 498 | |
| 499 | 499 | impl ParagraphTag { |
| 500 | 500 | /// The icon's side beside 10 to 17.5 pt text. |
| 501 | pub const SIZE: f32 = 12.0; | |
| 501 | pub(crate) const SIZE: f32 = 12.0; | |
| 502 | 502 | /// How far a 12 pt icon starts left of its text when no sibling has a list marker. |
| 503 | pub const INSET: f32 = 20.25; | |
| 503 | pub(crate) const INSET: f32 = 20.25; | |
| 504 | 504 | /// Space between a tag and the leftmost list marker among its paragraph's siblings. |
| 505 | 505 | const MARKER_GAP: f32 = 0.9; |
| 506 | 506 | |
| ... | ... | @@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>( |
| 1258 | 1258 | impl OutlineLayout { |
| 1259 | 1259 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each |
| 1260 | 1260 | /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. |
| 1261 | pub fn tag_column_offset(&self) -> f32 { | |
| 1261 | pub(crate) fn tag_column_offset(&self) -> f32 { | |
| 1262 | 1262 | -self |
| 1263 | 1263 | .paragraphs |
| 1264 | 1264 | .iter() |
| ... | ... | @@ -1296,7 +1296,7 @@ impl OutlineLayout { |
| 1296 | 1296 | } |
| 1297 | 1297 | |
| 1298 | 1298 | /// Innermost table cell containing a visible paragraph index. |
| 1299 | pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { | |
| 1299 | pub(crate) fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { | |
| 1300 | 1300 | self.tables.iter().rev().find_map(|table| { |
| 1301 | 1301 | let cell = table |
| 1302 | 1302 | .cells |
crates/canvas/src/reading.rs created+638| ... | ... | @@ -0,0 +1,638 @@ |
| 1 | //! Reading view: a page's content in reading order, reflowed into one column as wide as a | |
| 2 | //! phone's screen. The reflowed page is only shown; nothing of it is stored. | |
| 3 | //! | |
| 4 | //! Blocks are the page's outlines, pictures, files and drawings, as laid out. Blocks whose | |
| 5 | //! lines or pictures overlap, ink over text, and strokes within reach of each other join | |
| 6 | //! into one piece moved whole. Reading order is a recursive XY cut: rows split by horizontal | |
| 7 | //! gaps, then columns by vertical gaps, left column first. Notes beside one long outline are | |
| 8 | //! asides: the outline splits after the paragraph each note sits beside, and the note follows | |
| 9 | //! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size. | |
| 10 | ||
| 11 | use crate::{ | |
| 12 | editor::{CanvasEditor, EditorError, TextOutline, page::Content}, | |
| 13 | layout::TextEngine, | |
| 14 | }; | |
| 15 | use onestore::{ | |
| 16 | ExGuid, | |
| 17 | page::{Outline, Page, PageObject, ParagraphContent}, | |
| 18 | }; | |
| 19 | use std::{collections::BTreeMap, ops::Range}; | |
| 20 | ||
| 21 | /// Points between blocks in the column: OneNote's grid row. | |
| 22 | const GAP: f32 = 18.0; | |
| 23 | /// Strokes this near one another are one drawing, as handwriting is many strokes. | |
| 24 | const INK_REACH: f32 = 9.0; | |
| 25 | /// The share of the content's area drawn, or kept as laid out, past which the page is a | |
| 26 | /// canvas rather than a document. | |
| 27 | const CANVAS_SHARE: f64 = 0.5; | |
| 28 | /// How far below a paragraph's top a note beside it may start and still follow it. | |
| 29 | const ASIDE_SLACK: f32 = 6.0; | |
| 30 | /// How far an aside sits in from the column's edge, and the room above and below it. | |
| 31 | const ASIDE_INDENT: f32 = 18.0; | |
| 32 | const ASIDE_GAP: f32 = 6.0; | |
| 33 | ||
| 34 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 35 | pub enum Kind { | |
| 36 | Text, | |
| 37 | Picture, | |
| 38 | File, | |
| 39 | Drawing, | |
| 40 | /// Overlapping blocks or ink over text, moved as one piece. | |
| 41 | Group, | |
| 42 | } | |
| 43 | ||
| 44 | #[derive(Clone, Debug, PartialEq)] | |
| 45 | pub struct Block { | |
| 46 | pub kind: Kind, | |
| 47 | /// The page objects it moves. | |
| 48 | pub members: Vec<ExGuid>, | |
| 49 | /// `[x0, y0, x1, y1]` in page points, as laid out on the page. | |
| 50 | pub bounds: [f32; 4], | |
| 51 | /// The outline's top-level paragraphs it shows, where asides split the outline. | |
| 52 | pub part: Option<Range<usize>>, | |
| 53 | /// A note beside an outline, read after the paragraph it sits by. | |
| 54 | pub aside: bool, | |
| 55 | } | |
| 56 | ||
| 57 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 58 | pub enum Verdict { | |
| 59 | /// No wider than the column already: shown as laid out. | |
| 60 | Fits, | |
| 61 | /// Every block flows into the column. | |
| 62 | Reflows, | |
| 63 | /// Flows, with what each `Kept` names read in an order or shape the page decides. | |
| 64 | Partial(Vec<Kept>), | |
| 65 | /// Not offered. | |
| 66 | Refused(Refusal), | |
| 67 | } | |
| 68 | ||
| 69 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 70 | pub enum Kept { | |
| 71 | /// Notes beside an outline, each placed after the paragraph it sits beside. | |
| 72 | Asides, | |
| 73 | /// Blocks side by side, or interlocked, read left column first. | |
| 74 | SideBySide, | |
| 75 | /// Overlapping blocks or ink over text, moved whole at their size. | |
| 76 | Group, | |
| 77 | /// Something still wider than the column: a table with set column widths, a drawing, a | |
| 78 | /// group. | |
| 79 | Wide, | |
| 80 | } | |
| 81 | ||
| 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 83 | pub enum Refusal { | |
| 84 | /// Its positions read from the right; not yet reflowed. | |
| 85 | RightToLeft, | |
| 86 | /// Mostly ink. | |
| 87 | Drawn, | |
| 88 | /// Mostly overlapping blocks or ink over text. | |
| 89 | Arranged, | |
| 90 | } | |
| 91 | ||
| 92 | pub struct Reading { | |
| 93 | pub verdict: Verdict, | |
| 94 | /// In reading order where the page reflows, else as found. | |
| 95 | pub blocks: Vec<Block>, | |
| 96 | /// The page reflowed, where it reflows. | |
| 97 | pub page: Option<Page>, | |
| 98 | } | |
| 99 | ||
| 100 | impl Verdict { | |
| 101 | /// Whether the page has a reading view. | |
| 102 | pub fn offered(&self) -> bool { | |
| 103 | matches!(self, Self::Reflows | Self::Partial(_)) | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /// `page` read into a column `column` points wide. | |
| 108 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { | |
| 109 | let editor = CanvasEditor::from_page(page.clone(), engine)?; | |
| 110 | let blocks = blocks(&editor); | |
| 111 | let refused = |refusal, blocks| Reading { | |
| 112 | verdict: Verdict::Refused(refusal), | |
| 113 | blocks, | |
| 114 | page: None, | |
| 115 | }; | |
| 116 | if page.rtl { | |
| 117 | return Ok(refused(Refusal::RightToLeft, blocks)); | |
| 118 | } | |
| 119 | let left = blocks | |
| 120 | .iter() | |
| 121 | .map(|b| b.bounds[0]) | |
| 122 | .fold(f32::INFINITY, f32::min); | |
| 123 | let right = blocks | |
| 124 | .iter() | |
| 125 | .map(|b| b.bounds[2]) | |
| 126 | .fold(f32::NEG_INFINITY, f32::max); | |
| 127 | if blocks.is_empty() || right - left <= column + 1.0 { | |
| 128 | return Ok(Reading { | |
| 129 | verdict: Verdict::Fits, | |
| 130 | blocks, | |
| 131 | page: None, | |
| 132 | }); | |
| 133 | } | |
| 134 | let area = |kind: Option<Kind>| -> f64 { | |
| 135 | blocks | |
| 136 | .iter() | |
| 137 | .filter(|b| kind.is_none_or(|kind| b.kind == kind)) | |
| 138 | .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1])) | |
| 139 | .sum() | |
| 140 | }; | |
| 141 | let total = area(None).max(1.0); | |
| 142 | if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE { | |
| 143 | return Ok(refused(Refusal::Drawn, blocks)); | |
| 144 | } | |
| 145 | if area(Some(Kind::Group)) / total >= CANVAS_SHARE { | |
| 146 | return Ok(refused(Refusal::Arranged, blocks)); | |
| 147 | } | |
| 148 | let mut kept = Vec::new(); | |
| 149 | if blocks.iter().any(|b| b.kind == Kind::Group) { | |
| 150 | kept.push(Kept::Group); | |
| 151 | } | |
| 152 | let tops: BTreeMap<ExGuid, Vec<f32>> = editor | |
| 153 | .outlines() | |
| 154 | .iter() | |
| 155 | .map(|outline| (outline.id, root_tops(outline))) | |
| 156 | .collect(); | |
| 157 | let blocks = order(blocks, &tops, &mut kept); | |
| 158 | let start = editor.body_start().unwrap_or([left, blocks[0].bounds[1]]); | |
| 159 | let mut reflowed = page.clone(); | |
| 160 | let blocks = split(&mut reflowed, blocks); | |
| 161 | reflow( | |
| 162 | &mut reflowed, | |
| 163 | &blocks, | |
| 164 | [start[0].min(left), start[1]], | |
| 165 | column, | |
| 166 | engine, | |
| 167 | &mut kept, | |
| 168 | )?; | |
| 169 | kept.sort(); | |
| 170 | kept.dedup(); | |
| 171 | Ok(Reading { | |
| 172 | verdict: if kept.is_empty() { | |
| 173 | Verdict::Reflows | |
| 174 | } else { | |
| 175 | Verdict::Partial(kept) | |
| 176 | }, | |
| 177 | blocks, | |
| 178 | page: Some(reflowed), | |
| 179 | }) | |
| 180 | } | |
| 181 | ||
| 182 | /// Where `outline`'s lines, table cells and pictures lie, in page points. | |
| 183 | fn marks(outline: &TextOutline) -> Vec<[f32; 4]> { | |
| 184 | let [x, y] = outline.origin(); | |
| 185 | let shaped = outline.shaped(); | |
| 186 | let lines = outline.layouts().flat_map(|(_, paragraph)| { | |
| 187 | let [px, py] = paragraph.origin; | |
| 188 | paragraph.text.lines().map(move |(line, bounds)| { | |
| 189 | let metrics = line.metrics(); | |
| 190 | let left = x + px + metrics.offset; | |
| 191 | let top = y + py + bounds.top; | |
| 192 | [ | |
| 193 | left, | |
| 194 | top, | |
| 195 | left + metrics.advance - metrics.trailing_whitespace, | |
| 196 | top + bounds.height, | |
| 197 | ] | |
| 198 | }) | |
| 199 | }); | |
| 200 | let cells = shaped | |
| 201 | .tables | |
| 202 | .iter() | |
| 203 | .flat_map(|table| &table.cells) | |
| 204 | .map(|cell| cell.rect); | |
| 205 | let objects = shaped.objects.iter().map(|object| object.rect); | |
| 206 | lines | |
| 207 | .chain( | |
| 208 | cells | |
| 209 | .chain(objects) | |
| 210 | .map(|rect| crate::translated(rect, [x, y])), | |
| 211 | ) | |
| 212 | .filter(|[x0, y0, x1, y1]| x1 > x0 && y1 > y0) | |
| 213 | .collect() | |
| 214 | } | |
| 215 | ||
| 216 | /// The page top of each of `outline`'s top-level paragraphs: its own text's, or the first | |
| 217 | /// shown below it; the one before's where nothing below it shows. | |
| 218 | fn root_tops(outline: &TextOutline) -> Vec<f32> { | |
| 219 | let source = outline.snapshot(); | |
| 220 | let mut root_of = BTreeMap::new(); | |
| 221 | let mut roots = 0; | |
| 222 | for paragraph in &source.paragraphs { | |
| 223 | if paragraph.parent.is_none() { | |
| 224 | roots += 1; | |
| 225 | } | |
| 226 | root_of.insert(paragraph.id, roots - 1); | |
| 227 | } | |
| 228 | let y = outline.origin()[1]; | |
| 229 | let mut tops = vec![f32::INFINITY; roots]; | |
| 230 | for (_, paragraph) in outline.layouts() { | |
| 231 | if let Some(&root) = root_of.get(&paragraph.id) { | |
| 232 | tops[root] = tops[root].min(y + paragraph.origin[1]); | |
| 233 | } | |
| 234 | } | |
| 235 | let mut last = y; | |
| 236 | for top in &mut tops { | |
| 237 | if top.is_finite() { | |
| 238 | last = *top; | |
| 239 | } else { | |
| 240 | *top = last; | |
| 241 | } | |
| 242 | } | |
| 243 | tops | |
| 244 | } | |
| 245 | ||
| 246 | /// A page object as laid out: its identity, kind, bounds, and where its lines and pictures | |
| 247 | /// lie within them. | |
| 248 | type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); | |
| 249 | ||
| 250 | /// The page's content as laid out, overlapping pieces and nearby strokes joined. | |
| 251 | fn blocks(editor: &CanvasEditor) -> Vec<Block> { | |
| 252 | let mut items: Vec<Item> = editor | |
| 253 | .outlines() | |
| 254 | .iter() | |
| 255 | .filter(|outline| !outline.title && !outline.is_empty()) | |
| 256 | .map(|outline| { | |
| 257 | let b = outline.bounds(); | |
| 258 | let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); | |
| 259 | (outline.id, Kind::Text, bounds, marks(outline)) | |
| 260 | }) | |
| 261 | .collect(); | |
| 262 | for object in &editor.objects { | |
| 263 | let item = match object { | |
| 264 | Content::Outline { | |
| 265 | source, | |
| 266 | layout, | |
| 267 | below_title: None, | |
| 268 | } => { | |
| 269 | let [x, y] = crate::origin(&source.layout); | |
| 270 | Some(( | |
| 271 | source.id, | |
| 272 | Kind::Text, | |
| 273 | [x, y, x + layout.size[0], y + layout.size[1]], | |
| 274 | )) | |
| 275 | } | |
| 276 | Content::Image(image) if !image.background => { | |
| 277 | crate::outline::image_size(image).map(|[width, height]| { | |
| 278 | let [x, y] = crate::origin(&image.layout); | |
| 279 | (image.id, Kind::Picture, [x, y, x + width, y + height]) | |
| 280 | }) | |
| 281 | } | |
| 282 | Content::File { source, .. } => object.file().map(|(_, b)| (source.id, Kind::File, b)), | |
| 283 | Content::Ink(ink) => { | |
| 284 | crate::editor::page::ink_bounds(ink).map(|b| (ink.id, Kind::Drawing, b)) | |
| 285 | } | |
| 286 | Content::ReadOnly(object) if !matches!(object.source, PageObject::Title(_)) => { | |
| 287 | Some((object.source.id(), Kind::Picture, object.rect())) | |
| 288 | } | |
| 289 | _ => None, | |
| 290 | }; | |
| 291 | let item = item.filter(|(_, _, b)| b.iter().all(|v| v.is_finite())); | |
| 292 | items.extend(item.map(|(id, kind, bounds)| (id, kind, bounds, vec![bounds]))); | |
| 293 | } | |
| 294 | let mut parent: Vec<usize> = (0..items.len()).collect(); | |
| 295 | fn root(parent: &mut [usize], mut i: usize) -> usize { | |
| 296 | while parent[i] != i { | |
| 297 | parent[i] = parent[parent[i]]; | |
| 298 | i = parent[i]; | |
| 299 | } | |
| 300 | i | |
| 301 | } | |
| 302 | let meets = |a: &[f32; 4], b: &[f32; 4], reach: f32| { | |
| 303 | a[0] < b[2] + reach && b[0] < a[2] + reach && a[1] < b[3] + reach && b[1] < a[3] + reach | |
| 304 | }; | |
| 305 | for i in 0..items.len() { | |
| 306 | for j in i + 1..items.len() { | |
| 307 | let (a, b) = (&items[i], &items[j]); | |
| 308 | // Touching edges are not overlap. | |
| 309 | let reach = if a.1 == Kind::Drawing && b.1 == Kind::Drawing { | |
| 310 | INK_REACH | |
| 311 | } else { | |
| 312 | -1.0 | |
| 313 | }; | |
| 314 | if meets(&a.2, &b.2, reach) | |
| 315 | && a.3.iter().any(|m| b.3.iter().any(|n| meets(m, n, reach))) | |
| 316 | { | |
| 317 | let (ra, rb) = (root(&mut parent, i), root(&mut parent, j)); | |
| 318 | parent[ra] = rb; | |
| 319 | } | |
| 320 | } | |
| 321 | } | |
| 322 | let mut joined: BTreeMap<usize, Block> = BTreeMap::new(); | |
| 323 | for (i, (id, kind, bounds, _)) in items.into_iter().enumerate() { | |
| 324 | let r = root(&mut parent, i); | |
| 325 | joined | |
| 326 | .entry(r) | |
| 327 | .and_modify(|block| { | |
| 328 | block.members.push(id); | |
| 329 | if !(block.kind == Kind::Drawing && kind == Kind::Drawing) { | |
| 330 | block.kind = Kind::Group; | |
| 331 | } | |
| 332 | block.bounds = union(block.bounds, bounds); | |
| 333 | }) | |
| 334 | .or_insert(Block { | |
| 335 | kind, | |
| 336 | members: vec![id], | |
| 337 | bounds, | |
| 338 | part: None, | |
| 339 | aside: false, | |
| 340 | }); | |
| 341 | } | |
| 342 | joined.into_values().collect() | |
| 343 | } | |
| 344 | ||
| 345 | fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { | |
| 346 | [ | |
| 347 | a[0].min(b[0]), | |
| 348 | a[1].min(b[1]), | |
| 349 | a[2].max(b[2]), | |
| 350 | a[3].max(b[3]), | |
| 351 | ] | |
| 352 | } | |
| 353 | ||
| 354 | /// `blocks` in reading order by recursive XY cut: rows first, then columns left to right, | |
| 355 | /// notes beside a single outline as its asides. | |
| 356 | fn order( | |
| 357 | blocks: Vec<Block>, | |
| 358 | tops: &BTreeMap<ExGuid, Vec<f32>>, | |
| 359 | kept: &mut Vec<Kept>, | |
| 360 | ) -> Vec<Block> { | |
| 361 | if blocks.len() <= 1 { | |
| 362 | return blocks; | |
| 363 | } | |
| 364 | let mut parts = cut(blocks, 1); | |
| 365 | if let [row] = &mut parts[..] { | |
| 366 | let row = std::mem::take(row); | |
| 367 | if let Some(asides) = asides(&row, tops) { | |
| 368 | kept.push(Kept::Asides); | |
| 369 | return asides; | |
| 370 | } | |
| 371 | parts = cut(row, 0); | |
| 372 | let texts = parts | |
| 373 | .iter() | |
| 374 | .filter(|part| part.iter().any(|b| b.kind == Kind::Text)); | |
| 375 | if texts.count() > 1 { | |
| 376 | kept.push(Kept::SideBySide); | |
| 377 | } | |
| 378 | } | |
| 379 | if let [interlocked] = &mut parts[..] { | |
| 380 | // No gap runs across: top to bottom, then left to right. | |
| 381 | kept.push(Kept::SideBySide); | |
| 382 | let mut blocks = std::mem::take(interlocked); | |
| 383 | blocks.sort_by(|a, b| { | |
| 384 | a.bounds[1] | |
| 385 | .total_cmp(&b.bounds[1]) | |
| 386 | .then(a.bounds[0].total_cmp(&b.bounds[0])) | |
| 387 | }); | |
| 388 | return blocks; | |
| 389 | } | |
| 390 | parts | |
| 391 | .into_iter() | |
| 392 | .flat_map(|part| order(part, tops, kept)) | |
| 393 | .collect() | |
| 394 | } | |
| 395 | ||
| 396 | /// `row` as one outline split after each paragraph a note beside it sits by, where every | |
| 397 | /// other block is text no taller than the outline and clear of it across. | |
| 398 | fn asides(row: &[Block], tops: &BTreeMap<ExGuid, Vec<f32>>) -> Option<Vec<Block>> { | |
| 399 | let height = |b: &Block| b.bounds[3] - b.bounds[1]; | |
| 400 | let main = row | |
| 401 | .iter() | |
| 402 | .filter(|b| b.kind == Kind::Text) | |
| 403 | .max_by(|a, b| height(a).total_cmp(&height(b)))?; | |
| 404 | let tops = tops.get(&main.members[0])?; | |
| 405 | let mut notes: Vec<&Block> = row.iter().filter(|b| *b != main).collect(); | |
| 406 | let beside = |b: &Block| { | |
| 407 | b.kind == Kind::Text | |
| 408 | && height(b) < height(main) | |
| 409 | && (b.bounds[0] >= main.bounds[0] + 72.0 || b.bounds[2] <= main.bounds[0]) | |
| 410 | }; | |
| 411 | if notes.is_empty() || tops.len() < 2 || !notes.iter().all(|b| beside(b)) { | |
| 412 | return None; | |
| 413 | } | |
| 414 | notes.sort_by(|a, b| a.bounds[1].total_cmp(&b.bounds[1])); | |
| 415 | let mut out = Vec::new(); | |
| 416 | let mut from = 0; | |
| 417 | let slice = |range: Range<usize>| { | |
| 418 | let mut bounds = main.bounds; | |
| 419 | bounds[1] = tops[range.start]; | |
| 420 | bounds[3] = tops.get(range.end).copied().unwrap_or(main.bounds[3]); | |
| 421 | Block { | |
| 422 | part: Some(range), | |
| 423 | bounds, | |
| 424 | ..main.clone() | |
| 425 | } | |
| 426 | }; | |
| 427 | for note in notes { | |
| 428 | let after = tops | |
| 429 | .partition_point(|top| *top <= note.bounds[1] + ASIDE_SLACK) | |
| 430 | .max(1); | |
| 431 | if after > from { | |
| 432 | out.push(slice(from..after)); | |
| 433 | from = after; | |
| 434 | } | |
| 435 | out.push(Block { | |
| 436 | aside: true, | |
| 437 | ..note.clone() | |
| 438 | }); | |
| 439 | } | |
| 440 | if from < tops.len() { | |
| 441 | out.push(slice(from..tops.len())); | |
| 442 | } | |
| 443 | Some(out) | |
| 444 | } | |
| 445 | ||
| 446 | /// `blocks` split at each gap along `axis` (0 for x, 1 for y) that no block spans. | |
| 447 | fn cut(mut blocks: Vec<Block>, axis: usize) -> Vec<Vec<Block>> { | |
| 448 | blocks.sort_by(|a, b| a.bounds[axis].total_cmp(&b.bounds[axis])); | |
| 449 | let mut parts: Vec<Vec<Block>> = Vec::new(); | |
| 450 | let mut end = f32::NEG_INFINITY; | |
| 451 | for block in blocks { | |
| 452 | if parts.is_empty() || block.bounds[axis] >= end { | |
| 453 | parts.push(Vec::new()); | |
| 454 | } | |
| 455 | end = end.max(block.bounds[axis + 2]); | |
| 456 | parts.last_mut().unwrap().push(block); | |
| 457 | } | |
| 458 | parts | |
| 459 | } | |
| 460 | ||
| 461 | /// Splits each outline asides cut into one outline per part, shown only: the first part | |
| 462 | /// keeps the outline's identity, the others take ones derived from it. | |
| 463 | fn split(page: &mut Page, mut blocks: Vec<Block>) -> Vec<Block> { | |
| 464 | let mut whole: BTreeMap<ExGuid, Outline> = BTreeMap::new(); | |
| 465 | for block in &mut blocks { | |
| 466 | let Some(range) = block.part.clone() else { | |
| 467 | continue; | |
| 468 | }; | |
| 469 | let id = block.members[0]; | |
| 470 | let source = match whole.get(&id) { | |
| 471 | Some(source) => source.clone(), | |
| 472 | None => { | |
| 473 | let Some(PageObject::Outline(source)) = object_mut(page, id).cloned() else { | |
| 474 | continue; | |
| 475 | }; | |
| 476 | whole.insert(id, source.clone()); | |
| 477 | source | |
| 478 | } | |
| 479 | }; | |
| 480 | let mut root = 0; | |
| 481 | let paragraphs = source | |
| 482 | .paragraphs | |
| 483 | .iter() | |
| 484 | .filter(|paragraph| { | |
| 485 | if paragraph.parent.is_none() { | |
| 486 | root += 1; | |
| 487 | } | |
| 488 | range.contains(&(root - 1)) | |
| 489 | }) | |
| 490 | .cloned() | |
| 491 | .collect(); | |
| 492 | let part = Outline { | |
| 493 | id: if range.start == 0 { | |
| 494 | id | |
| 495 | } else { | |
| 496 | ExGuid { | |
| 497 | guid: id.guid, | |
| 498 | n: id.n ^ (0x8000_0000 | range.start as u32), | |
| 499 | } | |
| 500 | }, | |
| 501 | paragraphs, | |
| 502 | ..source | |
| 503 | }; | |
| 504 | block.members[0] = part.id; | |
| 505 | match object_mut(page, part.id) { | |
| 506 | Some(object) => *object = PageObject::Outline(part), | |
| 507 | None => page.objects.push(PageObject::Outline(part)), | |
| 508 | } | |
| 509 | } | |
| 510 | blocks | |
| 511 | } | |
| 512 | ||
| 513 | /// Stacks `blocks` in order from `start` down a column `column` wide. | |
| 514 | fn reflow( | |
| 515 | page: &mut Page, | |
| 516 | blocks: &[Block], | |
| 517 | start: [f32; 2], | |
| 518 | column: f32, | |
| 519 | engine: &mut TextEngine, | |
| 520 | kept: &mut Vec<Kept>, | |
| 521 | ) -> Result<(), EditorError> { | |
| 522 | let [left, mut y] = start; | |
| 523 | // Widths first: text rewraps, pictures shrink, the rest moves across whole. | |
| 524 | let mut heights = Vec::with_capacity(blocks.len()); | |
| 525 | for block in blocks { | |
| 526 | let [x0, y0, x1, y1] = block.bounds; | |
| 527 | let mut height = y1 - y0; | |
| 528 | let indent = if block.aside { ASIDE_INDENT } else { 0.0 }; | |
| 529 | match (block.kind, object_mut(page, block.members[0])) { | |
| 530 | (Kind::Text, Some(PageObject::Outline(outline))) => { | |
| 531 | outline.layout.x = Some(left + indent); | |
| 532 | outline.layout.max_width = Some((x1 - x0).min(column - indent)); | |
| 533 | outline.layout.reserved_width = None; | |
| 534 | outline.min_width = None; | |
| 535 | collapse_blanks(outline); | |
| 536 | for paragraph in &mut outline.paragraphs { | |
| 537 | if let ParagraphContent::Image(image) = &mut paragraph.content { | |
| 538 | shrink(image, column - 36.0); | |
| 539 | } | |
| 540 | } | |
| 541 | } | |
| 542 | (Kind::Picture, Some(PageObject::Image(image))) => { | |
| 543 | image.layout.x = Some(left); | |
| 544 | height = shrink(image, column).unwrap_or(height); | |
| 545 | } | |
| 546 | _ => { | |
| 547 | for &id in &block.members { | |
| 548 | if let Some(object) = object_mut(page, id) { | |
| 549 | let layout = object.layout_mut(); | |
| 550 | layout.x = Some(layout.x.unwrap_or(0.0) + left - x0); | |
| 551 | } | |
| 552 | } | |
| 553 | if x1 - x0 > column + 1.0 { | |
| 554 | kept.push(Kept::Wide); | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | heights.push(height); | |
| 559 | } | |
| 560 | // Text heights at the new widths. | |
| 561 | let laid = CanvasEditor::from_page(page.clone(), engine)?; | |
| 562 | for (block, height) in blocks.iter().zip(&mut heights) { | |
| 563 | if block.kind != Kind::Text { | |
| 564 | continue; | |
| 565 | } | |
| 566 | if let Some(outline) = laid.outlines().iter().find(|o| o.id == block.members[0]) { | |
| 567 | let b = outline.bounds(); | |
| 568 | *height = (b.y1 - b.y0) as f32; | |
| 569 | if (b.x1 - b.x0) as f32 > column + 1.0 { | |
| 570 | kept.push(Kept::Wide); | |
| 571 | } | |
| 572 | } | |
| 573 | } | |
| 574 | for (index, (block, height)) in blocks.iter().zip(&heights).enumerate() { | |
| 575 | // Asides and the parts of the outline they split sit close. | |
| 576 | let close = |block: &Block| block.aside || block.part.as_ref().is_some_and(|p| p.start > 0); | |
| 577 | if index > 0 { | |
| 578 | let previous = &blocks[index - 1]; | |
| 579 | y += if close(block) || previous.aside { | |
| 580 | ASIDE_GAP | |
| 581 | } else { | |
| 582 | GAP | |
| 583 | }; | |
| 584 | } | |
| 585 | let dy = y - block.bounds[1]; | |
| 586 | for (index, &id) in block.members.iter().enumerate() { | |
| 587 | if let Some(object) = object_mut(page, id) { | |
| 588 | let layout = object.layout_mut(); | |
| 589 | layout.y = Some( | |
| 590 | if index == 0 && matches!(block.kind, Kind::Text | Kind::Picture) { | |
| 591 | y | |
| 592 | } else { | |
| 593 | layout.y.unwrap_or(0.0) + dy | |
| 594 | }, | |
| 595 | ); | |
| 596 | } | |
| 597 | } | |
| 598 | y += height; | |
| 599 | } | |
| 600 | Ok(()) | |
| 601 | } | |
| 602 | ||
| 603 | /// Drops each blank top-level paragraph that follows another, as spacing kept a side | |
| 604 | /// column level with the text beside it. | |
| 605 | fn collapse_blanks(outline: &mut Outline) { | |
| 606 | let blank = |p: &onestore::page::PageParagraph| { | |
| 607 | p.lists.is_empty() | |
| 608 | && p.tags.is_empty() | |
| 609 | && p.text() | |
| 610 | .is_some_and(|t| t.tags.is_empty() && t.text.text().trim().is_empty()) | |
| 611 | }; | |
| 612 | let parents: std::collections::BTreeSet<ExGuid> = | |
| 613 | outline.paragraphs.iter().filter_map(|p| p.parent).collect(); | |
| 614 | let mut previous_blank = false; | |
| 615 | let mut root_kept = true; | |
| 616 | outline.paragraphs.retain(|p| { | |
| 617 | if p.parent.is_some() { | |
| 618 | return root_kept; | |
| 619 | } | |
| 620 | let is_blank = blank(p) && !parents.contains(&p.id); | |
| 621 | root_kept = !(is_blank && previous_blank); | |
| 622 | previous_blank = is_blank; | |
| 623 | root_kept | |
| 624 | }); | |
| 625 | } | |
| 626 | ||
| 627 | fn object_mut(page: &mut Page, id: ExGuid) -> Option<&mut PageObject> { | |
| 628 | page.objects.iter_mut().find(|object| object.id() == id) | |
| 629 | } | |
| 630 | ||
| 631 | /// Scales `image` down to `width` points across, keeping its shape; its new height. | |
| 632 | fn shrink(image: &mut onestore::page::Image, width: f32) -> Option<f32> { | |
| 633 | let [w, h] = crate::outline::image_size(image)?; | |
| 634 | let scale = (width / w).min(1.0); | |
| 635 | image.layout.max_width = Some(w * scale); | |
| 636 | image.layout.max_height = Some(h * scale); | |
| 637 | Some(h * scale) | |
| 638 | } |
crates/canvas/src/search.rs+20-45| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | //! case and diacritics; a page matches when all its words appear in its title or text, and |
| 3 | 3 | //! pages whose titles hold every word come first, then the most recently modified. |
| 4 | 4 | |
| 5 | use crate::document::TextPosition; | |
| 5 | use crate::document::{TextPosition, descendants}; | |
| 6 | 6 | use crate::editor::{CanvasEditor, Selection}; |
| 7 | 7 | use icu_normalizer::DecomposingNormalizerBorrowed; |
| 8 | 8 | use onestore::ExGuid; |
| ... | ... | @@ -67,7 +67,7 @@ impl Query { |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | /// Where the query's words start words of `text`, as byte ranges of it. |
| 70 | pub fn find(&self, text: &str) -> Vec<Range<usize>> { | |
| 70 | pub(crate) fn find(&self, text: &str) -> Vec<Range<usize>> { | |
| 71 | 71 | let (folded, source) = fold_mapped(text); |
| 72 | 72 | self.hits(&folded) |
| 73 | 73 | .into_iter() |
| ... | ... | @@ -180,25 +180,13 @@ pub fn shown(paragraph: &Paragraph) -> String { |
| 180 | 180 | /// Calls `visit` with each text paragraph of the page's outlines, in page order, tables |
| 181 | 181 | /// cell by cell. |
| 182 | 182 | fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) { |
| 183 | fn walk<'a>( | |
| 184 | paragraphs: &'a [PageParagraph], | |
| 185 | visit: &mut impl FnMut(&'a PageParagraph, &'a Paragraph), | |
| 186 | ) { | |
| 187 | for paragraph in paragraphs { | |
| 188 | match &paragraph.content { | |
| 189 | ParagraphContent::Text(text) => visit(paragraph, &text.text), | |
| 190 | ParagraphContent::Table(table) => { | |
| 191 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | |
| 192 | walk(&cell.paragraphs, visit); | |
| 193 | } | |
| 194 | } | |
| 195 | _ => {} | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | 183 | for object in &page.objects { |
| 200 | 184 | if let PageObject::Outline(outline) = object { |
| 201 | walk(&outline.paragraphs, &mut visit); | |
| 185 | for (_, _, node) in descendants(&outline.paragraphs, None) { | |
| 186 | if let ParagraphContent::Text(text) = &node.content { | |
| 187 | visit(node, &text.text); | |
| 188 | } | |
| 189 | } | |
| 202 | 190 | } |
| 203 | 191 | } |
| 204 | 192 | } |
| ... | ... | @@ -206,32 +194,19 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, |
| 206 | 194 | /// The text OneNote recognised in each of the page's pictures, or printed on a printout's |
| 207 | 195 | /// pages, in page order. |
| 208 | 196 | fn picture_text(page: &Page) -> Vec<&str> { |
| 209 | fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) { | |
| 210 | for paragraph in paragraphs { | |
| 211 | match &paragraph.content { | |
| 212 | ParagraphContent::Image(image) => { | |
| 213 | out.extend(image.text.as_ref().map(|text| text.text.as_str())) | |
| 214 | } | |
| 215 | ParagraphContent::Table(table) => { | |
| 216 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | |
| 217 | walk(&cell.paragraphs, out); | |
| 218 | } | |
| 219 | } | |
| 220 | _ => {} | |
| 221 | } | |
| 222 | } | |
| 223 | } | |
| 224 | let mut out = Vec::new(); | |
| 225 | for object in &page.objects { | |
| 226 | match object { | |
| 227 | PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), | |
| 228 | PageObject::Image(image) => { | |
| 229 | out.extend(image.text.as_ref().map(|text| text.text.as_str())) | |
| 230 | } | |
| 231 | _ => {} | |
| 232 | } | |
| 233 | } | |
| 234 | out | |
| 197 | let pictures = page.objects.iter().flat_map(|object| match object { | |
| 198 | PageObject::Outline(outline) => descendants(&outline.paragraphs, None) | |
| 199 | .filter_map(|(_, _, node)| match &node.content { | |
| 200 | ParagraphContent::Image(image) => Some(image), | |
| 201 | _ => None, | |
| 202 | }) | |
| 203 | .collect(), | |
| 204 | PageObject::Image(image) => vec![image], | |
| 205 | _ => Vec::new(), | |
| 206 | }); | |
| 207 | pictures | |
| 208 | .filter_map(|image| Some(image.text.as_ref()?.text.as_str())) | |
| 209 | .collect() | |
| 235 | 210 | } |
| 236 | 211 | |
| 237 | 212 | /// A page's text outside its title, a paragraph to a line, then the text in its pictures, |
crates/canvas/src/spelling.rs+6-6| ... | ... | @@ -60,10 +60,10 @@ pub fn pick(language: u32, available: &[String]) -> Option<&str> { |
| 60 | 60 | |
| 61 | 61 | /// A marked word: misspelled, or repeating the word before it. |
| 62 | 62 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 63 | pub struct Mark { | |
| 63 | pub(crate) struct Mark { | |
| 64 | 64 | /// Bytes of the paragraph's text. |
| 65 | pub range: Range<usize>, | |
| 66 | pub repeated: bool, | |
| 65 | pub(crate) range: Range<usize>, | |
| 66 | pub(crate) repeated: bool, | |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | /// A word of a paragraph: its bytes, its language, and whether its spelling is checked. |
| ... | ... | @@ -352,7 +352,7 @@ impl Spelling { |
| 352 | 352 | |
| 353 | 353 | /// The marks on `paragraph` once it is checked, less accepted words; until then none, |
| 354 | 354 | /// and it is checked. |
| 355 | pub fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> { | |
| 355 | pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> { | |
| 356 | 356 | let key = key(paragraph); |
| 357 | 357 | let mut state = self.shared.state.lock().unwrap(); |
| 358 | 358 | if let Some(marks) = state.marks(key, paragraph) { |
| ... | ... | @@ -366,7 +366,7 @@ impl Spelling { |
| 366 | 366 | |
| 367 | 367 | /// The marks on `paragraph`, checked on this thread where not yet checked, as the |
| 368 | 368 | /// Spelling pane walks the page. |
| 369 | pub fn marks_now(&self, paragraph: &Paragraph) -> Vec<Mark> { | |
| 369 | pub(crate) fn marks_now(&self, paragraph: &Paragraph) -> Vec<Mark> { | |
| 370 | 370 | let key = key(paragraph); |
| 371 | 371 | if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) { |
| 372 | 372 | return marks; |
| ... | ... | @@ -378,7 +378,7 @@ impl Spelling { |
| 378 | 378 | } |
| 379 | 379 | |
| 380 | 380 | /// Corrections for `word` in `language`, best first. |
| 381 | pub fn suggest(&self, word: &str, language: Option<u32>) -> Vec<String> { | |
| 381 | pub(crate) fn suggest(&self, word: &str, language: Option<u32>) -> Vec<String> { | |
| 382 | 382 | self.shared |
| 383 | 383 | .dictionary |
| 384 | 384 | .suggest(word, language.unwrap_or(crate::language::EN_US)) |
crates/canvas/tests/reading.rs created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | //! The reading view over OneNote-written pages: what it offers, the order it reads, and that | |
| 2 | //! the reflowed page keeps every paragraph and object while only the shown copy moves. | |
| 3 | ||
| 4 | use canvas::{ | |
| 5 | layout::TextEngine, | |
| 6 | reading::{self, Kept, Kind, Refusal, Verdict}, | |
| 7 | }; | |
| 8 | use onestore::{ | |
| 9 | Arena, ExGuid, Section, | |
| 10 | page::{Page, PageObject}, | |
| 11 | }; | |
| 12 | use std::collections::BTreeSet; | |
| 13 | ||
| 14 | const COLUMN: f32 = 230.0; | |
| 15 | ||
| 16 | fn first_page(bytes: &[u8]) -> Page { | |
| 17 | let arena = Arena::default(); | |
| 18 | let mut section = Section::open(&arena, bytes.to_vec()).unwrap(); | |
| 19 | let space = section.pages().unwrap()[0].0; | |
| 20 | section.page(space).unwrap() | |
| 21 | } | |
| 22 | ||
| 23 | fn paragraphs(page: &Page) -> BTreeSet<ExGuid> { | |
| 24 | page.objects | |
| 25 | .iter() | |
| 26 | .filter_map(|object| match object { | |
| 27 | PageObject::Outline(outline) => Some(outline.paragraphs.iter().map(|p| p.id)), | |
| 28 | _ => None, | |
| 29 | }) | |
| 30 | .flatten() | |
| 31 | .collect() | |
| 32 | } | |
| 33 | ||
| 34 | #[test] | |
| 35 | fn a_planning_page_reads_down_one_column() { | |
| 36 | let page = first_page(include_bytes!( | |
| 37 | "../../../corpus/notebook-management/native/page-color/Garden.one" | |
| 38 | )); | |
| 39 | let mut engine = TextEngine::default(); | |
| 40 | let read = reading::read(&page, COLUMN, &mut engine).unwrap(); | |
| 41 | assert_eq!( | |
| 42 | read.verdict, | |
| 43 | Verdict::Partial(vec![Kept::Asides, Kept::SideBySide, Kept::Wide]) | |
| 44 | ); | |
| 45 | let reflowed = read.page.unwrap(); | |
| 46 | // Splitting around asides drops no paragraph; only spacing blank runs could go. | |
| 47 | assert_eq!(paragraphs(&reflowed), paragraphs(&page)); | |
| 48 | // Every block starts at the column's left edge or an aside's indent, top to bottom. | |
| 49 | let mut tops = Vec::new(); | |
| 50 | for block in &read.blocks { | |
| 51 | let object = reflowed | |
| 52 | .objects | |
| 53 | .iter() | |
| 54 | .find(|object| object.id() == block.members[0]) | |
| 55 | .unwrap(); | |
| 56 | let layout = object.layout(); | |
| 57 | if block.kind == Kind::Text { | |
| 58 | assert!(layout.max_width.unwrap() <= COLUMN); | |
| 59 | } | |
| 60 | tops.push(layout.y.unwrap()); | |
| 61 | } | |
| 62 | assert!(tops.windows(2).all(|pair| pair[0] < pair[1]), "{tops:?}"); | |
| 63 | // The page itself is untouched. | |
| 64 | let again = first_page(include_bytes!( | |
| 65 | "../../../corpus/notebook-management/native/page-color/Garden.one" | |
| 66 | )); | |
| 67 | assert_eq!( | |
| 68 | serde_json::to_string(&page).unwrap(), | |
| 69 | serde_json::to_string(&again).unwrap() | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | #[test] | |
| 74 | fn a_drawing_is_not_reflowed() { | |
| 75 | let page = first_page(include_bytes!( | |
| 76 | "../../../corpus/ink-edit/drawing/candidate/ink.one" | |
| 77 | )); | |
| 78 | let read = reading::read(&page, COLUMN, &mut TextEngine::default()).unwrap(); | |
| 79 | assert_eq!(read.verdict, Verdict::Refused(Refusal::Drawn)); | |
| 80 | assert!(read.page.is_none()); | |
| 81 | } | |
| 82 | ||
| 83 | #[test] | |
| 84 | fn a_page_as_narrow_as_the_column_shows_as_laid_out() { | |
| 85 | let page = first_page(include_bytes!( | |
| 86 | "../../../corpus/append/round-01/complex/notebook/synthetic.one" | |
| 87 | )); | |
| 88 | let read = reading::read(&page, 2000.0, &mut TextEngine::default()).unwrap(); | |
| 89 | assert_eq!(read.verdict, Verdict::Fits); | |
| 90 | } |
crates/canvas/tests/rtl_page.rs created+188| ... | ... | @@ -0,0 +1,188 @@ |
| 1 | //! A right-to-left page through the page view, as a host drives it. OneNote 2010 keeps such a | |
| 2 | //! page's positions in the same left-to-right frame, its margin origin some 10,800 pt right of | |
| 3 | //! the page origin, and opens it scrolled to the right end of its content; the view does the | |
| 4 | //! same, and a click, a stroke and a drag store positions OneNote reads back where they were | |
| 5 | //! put. `SNOWBOUND_RTL_EXPORT` names a new directory receiving the edited section as a notebook | |
| 6 | //! for a cold reopen in OneNote 2010 (`corpus/rtl-page`). | |
| 7 | #![cfg(feature = "interaction")] | |
| 8 | ||
| 9 | use canvas::{ | |
| 10 | editor::Pen, | |
| 11 | gpu::page::PageScene, | |
| 12 | interaction::{PageView, ink::Tool}, | |
| 13 | layout::TextEngine, | |
| 14 | }; | |
| 15 | use onestore::{ | |
| 16 | Arena, ExGuid, Section, Store, | |
| 17 | op::{Edit, Op}, | |
| 18 | page::{Page, PageObject}, | |
| 19 | }; | |
| 20 | use std::time::{Duration, Instant}; | |
| 21 | ||
| 22 | const SOURCE: &[u8] = | |
| 23 | include_bytes!("../../../corpus/m6/native-page-direction-03/notebook/synthetic.one"); | |
| 24 | ||
| 25 | fn drag(view: &mut PageView, path: &[[f32; 2]]) { | |
| 26 | let _ = view.pointer_moved(path[0]).unwrap(); | |
| 27 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | |
| 28 | for point in &path[1..] { | |
| 29 | let _ = view.pointer_moved(*point).unwrap(); | |
| 30 | } | |
| 31 | let _ = view.pointer_released().unwrap(); | |
| 32 | } | |
| 33 | ||
| 34 | fn outline<'a>(page: &'a Page, text: &str) -> &'a onestore::page::Outline { | |
| 35 | page.objects | |
| 36 | .iter() | |
| 37 | .find_map(|object| match object { | |
| 38 | PageObject::Outline(outline) | |
| 39 | if outline.paragraphs.iter().any(|paragraph| { | |
| 40 | matches!(&paragraph.content, | |
| 41 | onestore::page::ParagraphContent::Text(run) if run.text.text() == text) | |
| 42 | }) => | |
| 43 | { | |
| 44 | Some(outline) | |
| 45 | } | |
| 46 | _ => None, | |
| 47 | }) | |
| 48 | .unwrap() | |
| 49 | } | |
| 50 | ||
| 51 | #[test] | |
| 52 | fn a_right_to_left_page_opens_at_its_right_end_and_stores_edits_in_onenotes_frame() { | |
| 53 | let arena = Arena::default(); | |
| 54 | let mut section = Section::open(&arena, SOURCE.to_vec()).unwrap(); | |
| 55 | let space: ExGuid = section.pages().unwrap()[0].0; | |
| 56 | let page = section.page(space).unwrap(); | |
| 57 | assert!(page.rtl); | |
| 58 | let margin = page.margin_origin; | |
| 59 | let ink_right = page | |
| 60 | .objects | |
| 61 | .iter() | |
| 62 | .filter_map(|object| match object { | |
| 63 | PageObject::Ink(ink) => ink.bounds().map(|b| b[0] + b[2] + ink.layout.x.unwrap()), | |
| 64 | _ => None, | |
| 65 | }) | |
| 66 | .fold(f32::NEG_INFINITY, f32::max); | |
| 67 | let mut engine = TextEngine::default(); | |
| 68 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 69 | let mut view = PageView::new( | |
| 70 | editor, | |
| 71 | engine, | |
| 72 | Some((scene, [0.0; 2])), | |
| 73 | [800, 600], | |
| 74 | 1.0, | |
| 75 | Duration::from_millis(500), | |
| 76 | ); | |
| 77 | ||
| 78 | // The view opens at the right end of the content, the drawings at its right edge. | |
| 79 | let scroll = view.scroll(); | |
| 80 | assert_eq!(view.viewport.origin[0], -scroll.max[0]); | |
| 81 | let right = view.viewport.document_point([800.0, 0.0])[0]; | |
| 82 | assert!( | |
| 83 | ink_right <= right && right < ink_right + 12.0, | |
| 84 | "{ink_right} {right}" | |
| 85 | ); | |
| 86 | assert_eq!(scroll.min[0], 0.0); | |
| 87 | // Resizing keeps the right edge, as OneNote's does. | |
| 88 | let _ = view.resized([600, 600]).unwrap(); | |
| 89 | assert_eq!(view.viewport.document_point([600.0, 0.0])[0], right); | |
| 90 | let _ = view.resized([800, 600]).unwrap(); | |
| 91 | assert_eq!(view.viewport.document_point([800.0, 0.0])[0], right); | |
| 92 | ||
| 93 | let mut at = 134_100_000_000_000_000; | |
| 94 | let mut store = |view: &mut PageView, section: &mut Section| { | |
| 95 | let ops = view.editor.take_ops().unwrap(); | |
| 96 | assert!(!ops.is_empty()); | |
| 97 | at += 10_000_000; | |
| 98 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | |
| 99 | section.apply("Snowbound Test", &Edit { at, ops }).unwrap(); | |
| 100 | section.page(space).unwrap() | |
| 101 | }; | |
| 102 | ||
| 103 | // A click on blank page puts an outline there, on the grid anchored at the margin origin. | |
| 104 | let click = [240.0, 480.0]; | |
| 105 | let point = view.viewport.document_point(click); | |
| 106 | let _ = view.pointer_moved(click).unwrap(); | |
| 107 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | |
| 108 | let _ = view.pointer_released().unwrap(); | |
| 109 | let _ = view | |
| 110 | .insert_text("Typed on a right-to-left page".into()) | |
| 111 | .unwrap(); | |
| 112 | let page = store(&mut view, &mut section); | |
| 113 | let typed = outline(&page, "Typed on a right-to-left page") | |
| 114 | .layout | |
| 115 | .clone(); | |
| 116 | let [x, y] = [typed.x.unwrap(), typed.y.unwrap()]; | |
| 117 | for (stored, clicked, origin) in [(x, point[0], margin[0]), (y, point[1], margin[1])] { | |
| 118 | assert!( | |
| 119 | ((stored - origin) / 18.0).fract().abs() < 1e-3, | |
| 120 | "{stored} {origin}" | |
| 121 | ); | |
| 122 | assert!((stored - clicked).abs() <= 18.0, "{stored} {clicked}"); | |
| 123 | } | |
| 124 | assert!(x > margin[0]); | |
| 125 | ||
| 126 | // A stroke lands in page points where the pen went. | |
| 127 | let _ = view.set_tool(Tool::Pen(Pen::new(1.0, Some(0x1f4e79)))); | |
| 128 | let path: Vec<[f32; 2]> = (0..=20) | |
| 129 | .map(|step| [300.0 + step as f32 * 5.0, 520.0 + step as f32 * 2.0]) | |
| 130 | .collect(); | |
| 131 | drag(&mut view, &path); | |
| 132 | let known: Vec<ExGuid> = page.objects.iter().map(PageObject::id).collect(); | |
| 133 | let page = store(&mut view, &mut section); | |
| 134 | let [PageObject::Ink(ink)] = page | |
| 135 | .objects | |
| 136 | .iter() | |
| 137 | .filter(|object| !known.contains(&object.id())) | |
| 138 | .collect::<Vec<_>>()[..] | |
| 139 | else { | |
| 140 | panic!("the stroke is not one new drawing") | |
| 141 | }; | |
| 142 | let offset = [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)]; | |
| 143 | let first = ink.strokes[0].points[0]; | |
| 144 | let expected = view.viewport.document_point(path[0]); | |
| 145 | for axis in 0..2 { | |
| 146 | assert!((first[axis] + offset[axis] - expected[axis]).abs() < 0.5); | |
| 147 | } | |
| 148 | let _ = view.set_tool(Tool::Select); | |
| 149 | ||
| 150 | // The header drags an outline two grid columns right, onto the grid anchored at the | |
| 151 | // margin origin. | |
| 152 | let before = outline(&page, "Fictitious positioned outline.") | |
| 153 | .layout | |
| 154 | .clone(); | |
| 155 | let header = [ | |
| 156 | before.x.unwrap() * view.viewport.scale + view.viewport.origin[0] + 40.0, | |
| 157 | before.y.unwrap() * view.viewport.scale + view.viewport.origin[1] - 8.0, | |
| 158 | ]; | |
| 159 | let steps: Vec<[f32; 2]> = (0..=12) | |
| 160 | .map(|step| [header[0] + step as f32 * 4.0, header[1] + step as f32 * 2.0]) | |
| 161 | .collect(); | |
| 162 | drag(&mut view, &steps); | |
| 163 | let page = store(&mut view, &mut section); | |
| 164 | let after = outline(&page, "Fictitious positioned outline.") | |
| 165 | .layout | |
| 166 | .clone(); | |
| 167 | assert_eq!(after.x.unwrap() - before.x.unwrap(), 36.0); | |
| 168 | assert!(after.y.unwrap() > before.y.unwrap()); | |
| 169 | assert_eq!(((after.y.unwrap() - margin[1]) / 18.0).fract(), 0.0); | |
| 170 | ||
| 171 | section.seal().unwrap(); | |
| 172 | let written = section.image(); | |
| 173 | if let Some(directory) = std::env::var_os("SNOWBOUND_RTL_EXPORT") { | |
| 174 | let directory = std::path::PathBuf::from(directory); | |
| 175 | std::fs::create_dir(&directory).unwrap(); | |
| 176 | std::fs::write(directory.join("synthetic.one"), &written).unwrap(); | |
| 177 | let file_id = Store::parse(&written).unwrap().header.file_id; | |
| 178 | std::fs::write( | |
| 179 | directory.join("Open Notebook.onetoc2"), | |
| 180 | onestore::create_table_of_contents( | |
| 181 | "Open Notebook.onetoc2", | |
| 182 | &[("synthetic.one", file_id)], | |
| 183 | ) | |
| 184 | .unwrap(), | |
| 185 | ) | |
| 186 | .unwrap(); | |
| 187 | } | |
| 188 | } |
crates/draw/Cargo.toml+13-3| ... | ... | @@ -7,10 +7,10 @@ publish = false |
| 7 | 7 | [features] |
| 8 | 8 | default = ["wgpu"] |
| 9 | 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through |
| 10 | # OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as | |
| 11 | # Windows 7 has no Direct3D 12. | |
| 10 | # OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its | |
| 11 | # maker picks one, as Windows 7 has no Direct3D 12. | |
| 12 | 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] |
| 13 | wgpu = ["render", "dep:wgpu"] | |
| 13 | wgpu = ["render", "dep:wgpu", "dep:windows"] | |
| 14 | 14 | # Writes layers as PDF pages, text as real text in subset fonts. |
| 15 | 15 | pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] |
| 16 | 16 | |
| ... | ... | @@ -28,6 +28,16 @@ subsetter = { version = "0.2.6", default-features = false, optional = true } |
| 28 | 28 | swash = { version = "0.2.10", optional = true } |
| 29 | 29 | wgpu = { workspace = true, optional = true } |
| 30 | 30 | |
| 31 | [target.'cfg(windows)'.dependencies] | |
| 32 | windows = { version = "0.62", default-features = false, optional = true, features = [ | |
| 33 | "Win32_Graphics_Direct3D", | |
| 34 | "Win32_Graphics_Direct3D11", | |
| 35 | "Win32_Graphics_DirectComposition", | |
| 36 | "Win32_Graphics_Dxgi_Common", | |
| 37 | "Win32_System_LibraryLoader", | |
| 38 | "Win32_UI_WindowsAndMessaging", | |
| 39 | ] } | |
| 40 | ||
| 31 | 41 | [dev-dependencies] |
| 32 | 42 | pollster = "0.4" |
| 33 | 43 | png = "0.18" |
crates/draw/src/draw.hlsl created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | // Shader model 4.0 port of draw.wgsl, for Direct3D 11 from feature level 10_0. The render | |
| 2 | // module embeds its bytecode, compiled by render/dxbc/compile.ps1. | |
| 3 | struct Vertex { | |
| 4 | float4 position : SV_Position; | |
| 5 | float2 uv : TEXCOORD0; | |
| 6 | float4 color : TEXCOORD1; | |
| 7 | float2 local : TEXCOORD2; | |
| 8 | nointerpolation float4 shape : TEXCOORD3; | |
| 9 | nointerpolation float stroke : TEXCOORD4; | |
| 10 | float2 clip_local : TEXCOORD5; | |
| 11 | nointerpolation float3 clip : TEXCOORD6; | |
| 12 | nointerpolation float blur : TEXCOORD7; | |
| 13 | }; | |
| 14 | ||
| 15 | Texture2D atlas : register(t0); | |
| 16 | SamplerState atlas_sampler : register(s0); | |
| 17 | ||
| 18 | Vertex vertex(float2 position : TEXCOORD0, float2 uv : TEXCOORD1, float4 color : TEXCOORD2, | |
| 19 | float2 local : TEXCOORD3, float4 shape : TEXCOORD4, float stroke : TEXCOORD5, | |
| 20 | float2 clip_local : TEXCOORD6, float3 clip : TEXCOORD7, float blur : TEXCOORD8) { | |
| 21 | Vertex output; | |
| 22 | output.position = float4(position, 0.0, 1.0); | |
| 23 | output.uv = uv; | |
| 24 | output.color = color; | |
| 25 | output.local = local; | |
| 26 | output.shape = shape; | |
| 27 | output.stroke = stroke; | |
| 28 | output.clip_local = clip_local; | |
| 29 | output.clip = clip; | |
| 30 | output.blur = blur; | |
| 31 | return output; | |
| 32 | } | |
| 33 | ||
| 34 | float ellipse_arc(float angle, float2 radius) { | |
| 35 | if (radius.x == radius.y) { | |
| 36 | return angle * radius.x; | |
| 37 | } | |
| 38 | // Four-point Gauss-Legendre quadrature. | |
| 39 | const float4 nodes = float4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); | |
| 40 | const float4 weights = float4(0.34785485, 0.65214515, 0.65214515, 0.34785485); | |
| 41 | float half_angle = angle * 0.5; | |
| 42 | float scale = max(radius.x, radius.y); | |
| 43 | float sum = 0.0; | |
| 44 | [unroll] for (int i = 0; i < 4; i++) { | |
| 45 | float t = half_angle * (nodes[i] + 1.0); | |
| 46 | sum += weights[i] * length((radius / scale) * float2(sin(t), cos(t))); | |
| 47 | } | |
| 48 | return half_angle * sum * scale; | |
| 49 | } | |
| 50 | ||
| 51 | float2 erf(float2 x) { | |
| 52 | float2 s = sign(x); | |
| 53 | float2 a = abs(x); | |
| 54 | float2 y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; | |
| 55 | y *= y; | |
| 56 | return s - s / (y * y); | |
| 57 | } | |
| 58 | ||
| 59 | float shadow(float2 p, float2 half_size, float corner, float sigma) { | |
| 60 | float start = clamp(-3.0 * sigma, p.y - half_size.y, p.y + half_size.y); | |
| 61 | float end = clamp(3.0 * sigma, p.y - half_size.y, p.y + half_size.y); | |
| 62 | float stride = (end - start) / 4.0; | |
| 63 | float y = start + stride * 0.5; | |
| 64 | float value = 0.0; | |
| 65 | [unroll] for (int i = 0; i < 4; i++) { | |
| 66 | float delta = min(half_size.y - corner - abs(p.y - y), 0.0); | |
| 67 | float curved = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta)); | |
| 68 | float2 integral = 0.5 + 0.5 * erf((p.x + float2(-curved, curved)) * (0.70710678 / sigma)); | |
| 69 | float weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma); | |
| 70 | value += (integral.y - integral.x) * weight * stride; | |
| 71 | y += stride; | |
| 72 | } | |
| 73 | return value; | |
| 74 | } | |
| 75 | ||
| 76 | float taper(float2 p, float h, float r0, float r1) { | |
| 77 | float2 q = float2(abs(p.y), p.x + h); | |
| 78 | float span = 2.0 * h; | |
| 79 | float b = (r0 - r1) / max(span, 1e-6); | |
| 80 | float far = length(q - float2(0.0, span)) - r1; | |
| 81 | if (abs(b) >= 1.0) { | |
| 82 | return min(length(q) - r0, far); | |
| 83 | } | |
| 84 | float a = sqrt(1.0 - b * b); | |
| 85 | float k = dot(q, float2(-b, a)); | |
| 86 | if (k < 0.0) { | |
| 87 | return length(q) - r0; | |
| 88 | } | |
| 89 | if (k > a * span) { | |
| 90 | return far; | |
| 91 | } | |
| 92 | return dot(q, float2(a, b)) - r0; | |
| 93 | } | |
| 94 | ||
| 95 | float4 fragment(Vertex input) : SV_Target { | |
| 96 | float4 color = atlas.Sample(atlas_sampler, input.uv) * input.color; | |
| 97 | if (input.blur > 0.0) { | |
| 98 | color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur); | |
| 99 | } else if (input.blur < 0.0) { | |
| 100 | color *= saturate(0.5 - taper(input.local, input.shape.x, input.shape.y, input.shape.z)); | |
| 101 | } else if (input.shape.x > 0.0 && input.shape.y > 0.0) { | |
| 102 | float2 q = abs(input.local) - input.shape.xy + input.shape.zw; | |
| 103 | float distance; | |
| 104 | if (input.shape.z == input.shape.w) { | |
| 105 | distance = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - input.shape.z; | |
| 106 | } else if (all(q > 0.0)) { | |
| 107 | float2 radius = max(input.shape.zw, 0.0001); | |
| 108 | float2 normalized = q / radius; | |
| 109 | float k0 = length(normalized); | |
| 110 | distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); | |
| 111 | } else { | |
| 112 | float2 edge = abs(input.local) - input.shape.xy; | |
| 113 | distance = max(edge.x, edge.y); | |
| 114 | } | |
| 115 | float coverage = saturate(0.5 - distance); | |
| 116 | float width = abs(input.stroke); | |
| 117 | if (width > 0.0) { | |
| 118 | coverage *= saturate(distance + width + 0.5); | |
| 119 | } | |
| 120 | if (input.stroke < 0.0) { | |
| 121 | float2 radius = max(input.shape.zw - width * 0.5, 0.0); | |
| 122 | if (any(radius == 0.0)) { | |
| 123 | radius = 0.0; | |
| 124 | } | |
| 125 | float2 straight = input.shape.xy - width * 0.5 - radius; | |
| 126 | float2 p = abs(input.local); | |
| 127 | float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); | |
| 128 | float along; | |
| 129 | if (p.y <= straight.y) { | |
| 130 | along = p.y; | |
| 131 | } else if (p.x <= straight.x || radius.x == 0.0) { | |
| 132 | along = quarter - p.x; | |
| 133 | } else { | |
| 134 | float angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); | |
| 135 | along = straight.y + ellipse_arc(angle, radius); | |
| 136 | } | |
| 137 | if (input.local.x < 0.0) { | |
| 138 | along = input.local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along; | |
| 139 | } else if (input.local.y < 0.0) { | |
| 140 | along = 4.0 * quarter - along; | |
| 141 | } | |
| 142 | float phase = frac(along / (4.0 * width)) * (4.0 * width); | |
| 143 | coverage *= saturate(width + 0.5 - abs(phase - 2.0 * width)); | |
| 144 | } | |
| 145 | color *= coverage; | |
| 146 | } | |
| 147 | if (input.clip.x > 0.0) { | |
| 148 | float2 q = abs(input.clip_local) - input.clip.xy + input.clip.z; | |
| 149 | float distance = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - input.clip.z; | |
| 150 | color *= saturate(0.5 - distance); | |
| 151 | } | |
| 152 | return color; | |
| 153 | } |
crates/draw/src/edit.rs+2-16| ... | ... | @@ -432,25 +432,11 @@ pub fn caret_blink(since: Duration) -> (f32, Option<Duration>) { |
| 432 | 432 | /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright |
| 433 | 433 | /// as AppKit's layers do, which blend in sRGB rather than linear light. |
| 434 | 434 | pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] { |
| 435 | let encode = |value: f32| { | |
| 436 | if value <= 0.003_130_8 { | |
| 437 | value * 12.92 | |
| 438 | } else { | |
| 439 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 440 | } | |
| 441 | }; | |
| 442 | let decode = |value: f32| { | |
| 443 | if value <= 0.040_45 { | |
| 444 | value / 12.92 | |
| 445 | } else { | |
| 446 | ((value + 0.055) / 1.055).powf(2.4) | |
| 447 | } | |
| 448 | }; | |
| 449 | 435 | let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; |
| 450 | 436 | let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); |
| 451 | 437 | let shown: [f32; 3] = std::array::from_fn(|channel| { |
| 452 | let [under, over] = [under[channel], over[channel]].map(encode); | |
| 453 | decode(under + opacity * (over - under)) | |
| 438 | let [under, over] = [under[channel], over[channel]].map(crate::encode); | |
| 439 | crate::linear(under + opacity * (over - under)) | |
| 454 | 440 | }); |
| 455 | 441 | let span = luminance(over) - luminance(under); |
| 456 | 442 | let alpha = if span.abs() < 1e-4 { |
crates/draw/src/lib.rs+20-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | //! What the page and the interface share: painting layers of glyphs, icons, paths, |
| 2 | //! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its | |
| 3 | //! own transform and clip, and editing text within a laid-out line. | |
| 2 | //! images, rounded rectangles and pen strokes with wgpu, Direct3D 11 or OpenGL, each layer | |
| 3 | //! with its own transform and clip, and editing text within a laid-out line. | |
| 4 | 4 | |
| 5 | 5 | pub mod edit; |
| 6 | 6 | #[cfg(feature = "render")] |
| ... | ... | @@ -8,3 +8,21 @@ mod render; |
| 8 | 8 | |
| 9 | 9 | #[cfg(feature = "render")] |
| 10 | 10 | pub use render::*; |
| 11 | ||
| 12 | /// sRGB's encoding of a linear channel. | |
| 13 | fn encode(linear: f32) -> f32 { | |
| 14 | if linear <= 0.003_130_8 { | |
| 15 | linear * 12.92 | |
| 16 | } else { | |
| 17 | 1.055 * linear.powf(1.0 / 2.4) - 0.055 | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | /// The linear light of an sRGB-encoded channel. | |
| 22 | fn linear(encoded: f32) -> f32 { | |
| 23 | if encoded <= 0.040_45 { | |
| 24 | encoded / 12.92 | |
| 25 | } else { | |
| 26 | ((encoded + 0.055) / 1.055).powf(2.4) | |
| 27 | } | |
| 28 | } |
crates/draw/src/render.rs+197-158| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | #[cfg(all(feature = "wgpu", windows))] |
| 2 | mod d3d11; | |
| 3 | #[cfg(all(feature = "wgpu", windows))] | |
| 2 | 4 | mod dual; |
| 3 | 5 | #[cfg(any(windows, not(feature = "wgpu")))] |
| 4 | 6 | mod gl; |
| ... | ... | @@ -19,8 +21,6 @@ use gl as backend; |
| 19 | 21 | use webgpu as backend; |
| 20 | 22 | |
| 21 | 23 | pub use backend::Target; |
| 22 | #[cfg(all(feature = "wgpu", windows))] | |
| 23 | pub use gl::Target as GlTarget; | |
| 24 | 24 | pub use icon::{Palette, picture_icon}; |
| 25 | 25 | #[cfg(feature = "pdf")] |
| 26 | 26 | pub use pdf::{Sheet, pdf}; |
| ... | ... | @@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob; |
| 34 | 34 | use parley::fontique::Blob; |
| 35 | 35 | use std::{ |
| 36 | 36 | collections::{HashMap, HashSet}, |
| 37 | ffi::CStr, | |
| 38 | mem::offset_of, | |
| 37 | 39 | ops::Range, |
| 38 | 40 | }; |
| 39 | 41 | use swash::{ |
| ... | ... | @@ -171,14 +173,25 @@ impl RasterImage { |
| 171 | 173 | } |
| 172 | 174 | } |
| 173 | 175 | |
| 174 | /// What a backend submits: the prepared vertices and batches, and the images they paint. | |
| 176 | /// What a backend submits: each group's batches, painted into an offscreen picture of its | |
| 177 | /// own, then the batches painting the target, and the images they show. | |
| 175 | 178 | struct Frame<'a> { |
| 176 | 179 | vertices: &'a [Vertex], |
| 180 | groups: &'a [Vec<Batch>], | |
| 177 | 181 | batches: &'a [Batch], |
| 178 | groups: &'a [Group], | |
| 179 | 182 | images: &'a HashMap<u64, CachedImage>, |
| 180 | 183 | } |
| 181 | 184 | |
| 185 | impl Frame<'_> { | |
| 186 | /// The texture image `id` uploaded as, as the backend drawing it holds it. | |
| 187 | fn image<T>(&self, id: u64) -> &T | |
| 188 | where | |
| 189 | backend::Image: AsRef<T>, | |
| 190 | { | |
| 191 | self.images[&id].texture.as_ref() | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 182 | 195 | struct CachedImage { |
| 183 | 196 | texture: backend::Image, |
| 184 | 197 | bytes: u64, |
| ... | ... | @@ -192,24 +205,58 @@ struct Batch { |
| 192 | 205 | scissor: [u32; 4], |
| 193 | 206 | } |
| 194 | 207 | |
| 195 | /// Batches that paint offscreen as one, then onto the target by `composite`'s vertices. | |
| 196 | struct Group { | |
| 197 | batches: Range<usize>, | |
| 198 | composite: Range<u32>, | |
| 199 | } | |
| 200 | ||
| 201 | /// How a batch meets what lies beneath it. | |
| 208 | /// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints | |
| 209 | /// a picture. | |
| 202 | 210 | #[derive(Clone, Copy, PartialEq)] |
| 203 | 211 | enum Blend { |
| 204 | 212 | Over, |
| 205 | 213 | /// A premultiplied picture over it. |
| 206 | 214 | Image(u64), |
| 215 | /// That group's offscreen picture over it. | |
| 216 | Group(usize), | |
| 207 | 217 | /// Clearing it by the batch's coverage. |
| 208 | 218 | Erase, |
| 209 | 219 | /// Multiplying it by the batch's colour where covered. |
| 210 | 220 | Multiply, |
| 211 | 221 | } |
| 212 | 222 | |
| 223 | #[derive(Clone, Copy)] | |
| 224 | enum Factor { | |
| 225 | Zero, | |
| 226 | One, | |
| 227 | SourceAlpha, | |
| 228 | OneMinusSourceAlpha, | |
| 229 | Destination, | |
| 230 | } | |
| 231 | ||
| 232 | impl Blend { | |
| 233 | /// Each blend state a backend makes: colour's source and destination factors, then | |
| 234 | /// alpha's, every one adding. | |
| 235 | const STATES: [[Factor; 4]; 4] = { | |
| 236 | use Factor::*; | |
| 237 | [ | |
| 238 | // Coverage accumulates in alpha, leaving premultiplied colour over a transparent | |
| 239 | // clear. | |
| 240 | [SourceAlpha, OneMinusSourceAlpha, One, OneMinusSourceAlpha], | |
| 241 | [One, OneMinusSourceAlpha, One, OneMinusSourceAlpha], | |
| 242 | [Zero, OneMinusSourceAlpha, Zero, OneMinusSourceAlpha], | |
| 243 | // The fragment's colour is scaled by its coverage, so this leaves the target times | |
| 244 | // the colour where covered and the target elsewhere; alpha stays. | |
| 245 | [Destination, OneMinusSourceAlpha, Zero, One], | |
| 246 | ] | |
| 247 | }; | |
| 248 | ||
| 249 | /// This blend's index in `STATES`. | |
| 250 | fn state(self) -> usize { | |
| 251 | match self { | |
| 252 | Blend::Over => 0, | |
| 253 | Blend::Image(_) | Blend::Group(_) => 1, | |
| 254 | Blend::Erase => 2, | |
| 255 | Blend::Multiply => 3, | |
| 256 | } | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 213 | 260 | #[repr(C)] |
| 214 | 261 | #[derive(Clone, Copy, Pod, Zeroable)] |
| 215 | 262 | struct Vertex { |
| ... | ... | @@ -228,6 +275,19 @@ struct Vertex { |
| 228 | 275 | blur: f32, |
| 229 | 276 | } |
| 230 | 277 | |
| 278 | /// Each `Vertex` field's name in the shaders, float count and offset, in shader input order. | |
| 279 | const ATTRIBUTES: [(&CStr, usize, usize); 9] = [ | |
| 280 | (c"position", 2, offset_of!(Vertex, position)), | |
| 281 | (c"uv", 2, offset_of!(Vertex, uv)), | |
| 282 | (c"color", 4, offset_of!(Vertex, color)), | |
| 283 | (c"local", 2, offset_of!(Vertex, local)), | |
| 284 | (c"shape", 4, offset_of!(Vertex, shape)), | |
| 285 | (c"stroke", 1, offset_of!(Vertex, stroke)), | |
| 286 | (c"clip_local", 2, offset_of!(Vertex, clip_local)), | |
| 287 | (c"clip", 3, offset_of!(Vertex, clip)), | |
| 288 | (c"blur", 1, offset_of!(Vertex, blur)), | |
| 289 | ]; | |
| 290 | ||
| 231 | 291 | #[derive(Hash, PartialEq, Eq)] |
| 232 | 292 | enum AtlasKey { |
| 233 | 293 | Text { |
| ... | ... | @@ -313,6 +373,18 @@ impl Motion { |
| 313 | 373 | } |
| 314 | 374 | } |
| 315 | 375 | |
| 376 | impl Layer<'_> { | |
| 377 | fn space(&self, size: [u32; 2]) -> Space { | |
| 378 | Space { | |
| 379 | size, | |
| 380 | scale: self.scale, | |
| 381 | origin: self.origin, | |
| 382 | backdrop: self.backdrop, | |
| 383 | round: self.round, | |
| 384 | } | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 316 | 388 | /// A layer's transform onto the target. |
| 317 | 389 | #[derive(Clone, Copy)] |
| 318 | 390 | struct Space { |
| ... | ... | @@ -324,13 +396,18 @@ struct Space { |
| 324 | 396 | } |
| 325 | 397 | |
| 326 | 398 | impl Space { |
| 399 | /// The device point at layer point `point`. | |
| 400 | fn point(&self, point: [f32; 2]) -> [f32; 2] { | |
| 401 | [0, 1].map(|axis| point[axis] * self.scale + self.origin[axis]) | |
| 402 | } | |
| 403 | ||
| 404 | /// The device bounds of layer bounds `rect`. | |
| 405 | fn rect(&self, rect: [f32; 4]) -> [f32; 4] { | |
| 406 | [0, 1, 2, 3].map(|edge| rect[edge] * self.scale + self.origin[edge % 2]) | |
| 407 | } | |
| 408 | ||
| 327 | 409 | fn visible_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { |
| 328 | let rect = [ | |
| 329 | rect[0] * self.scale + self.origin[0], | |
| 330 | rect[1] * self.scale + self.origin[1], | |
| 331 | rect[2] * self.scale + self.origin[0], | |
| 332 | rect[3] * self.scale + self.origin[1], | |
| 333 | ]; | |
| 410 | let rect = self.rect(rect); | |
| 334 | 411 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { |
| 335 | 412 | return Err(RenderError::InvalidPrimitive); |
| 336 | 413 | } |
| ... | ... | @@ -508,7 +585,7 @@ pub struct Renderer { |
| 508 | 585 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, |
| 509 | 586 | images: HashMap<u64, CachedImage>, |
| 510 | 587 | batches: Vec<Batch>, |
| 511 | groups: Vec<Group>, | |
| 588 | groups: Vec<Vec<Batch>>, | |
| 512 | 589 | /// Batches before this one take no more primitives. |
| 513 | 590 | barrier: usize, |
| 514 | 591 | scaler: ScaleContext, |
| ... | ... | @@ -516,10 +593,36 @@ pub struct Renderer { |
| 516 | 593 | row_height: u32, |
| 517 | 594 | } |
| 518 | 595 | |
| 596 | #[cfg(any(windows, not(feature = "wgpu")))] | |
| 597 | impl Renderer { | |
| 598 | /// Draws with the OpenGL context current on this thread, which must stay current | |
| 599 | /// whenever the renderer or a `Target` is used. | |
| 600 | pub fn opengl() -> Result<Self, String> { | |
| 601 | gl::Gpu::new().map(Self::with_gpu) | |
| 602 | } | |
| 603 | ||
| 604 | /// An offscreen frame `size` pixels large, for Direct3D 11 or OpenGL. | |
| 605 | pub fn target(&self, size: [u32; 2]) -> Result<Target, String> { | |
| 606 | self.gpu.target(size) | |
| 607 | } | |
| 608 | ||
| 609 | /// Shows `target` in the window, premultiplying each pixel again in sRGB if | |
| 610 | /// `translucent`, as the desktop compositing the window over its backdrop needs. | |
| 611 | /// OpenGL's frame waits for its context's buffers to swap. | |
| 612 | pub fn present(&self, target: &Target, translucent: bool) { | |
| 613 | self.gpu.present(target, translucent); | |
| 614 | } | |
| 615 | ||
| 616 | /// The sRGB RGBA rows of `target`, from `Renderer::target`, top first. | |
| 617 | pub fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> { | |
| 618 | self.gpu.read_pixels(target) | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 519 | 622 | impl Renderer { |
| 520 | fn with_gpu(gpu: backend::Gpu) -> Self { | |
| 521 | let renderer = Self { | |
| 522 | gpu, | |
| 623 | fn with_gpu(gpu: impl Into<backend::Gpu>) -> Self { | |
| 624 | let mut renderer = Self { | |
| 625 | gpu: gpu.into(), | |
| 523 | 626 | vertices: Vec::new(), |
| 524 | 627 | glyphs: HashMap::new(), |
| 525 | 628 | images: HashMap::new(), |
| ... | ... | @@ -527,10 +630,10 @@ impl Renderer { |
| 527 | 630 | groups: Vec::new(), |
| 528 | 631 | barrier: 0, |
| 529 | 632 | scaler: ScaleContext::with_max_entries(32), |
| 530 | pen: [1, 0], | |
| 531 | row_height: 1, | |
| 633 | pen: [0; 2], | |
| 634 | row_height: 0, | |
| 532 | 635 | }; |
| 533 | renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 636 | renderer.clear_glyph_cache(); | |
| 534 | 637 | renderer |
| 535 | 638 | } |
| 536 | 639 | |
| ... | ... | @@ -539,23 +642,12 @@ impl Renderer { |
| 539 | 642 | self.gpu.max_texture_dimension() |
| 540 | 643 | } |
| 541 | 644 | |
| 542 | fn atlas_side(&self) -> u32 { | |
| 543 | self.gpu.atlas_side() | |
| 544 | } | |
| 545 | ||
| 546 | /// Replaces the atlas with an empty one twice as wide and tall. | |
| 547 | fn grow_atlas(&mut self) { | |
| 548 | self.gpu.new_atlas(self.atlas_side() * 2); | |
| 549 | self.clear_glyph_cache(); | |
| 550 | self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 551 | } | |
| 552 | ||
| 553 | 645 | fn glyph_limit(&self) -> usize { |
| 554 | MAX_GLYPHS * (self.atlas_side() / ATLAS_SIZE).pow(2) as usize | |
| 646 | MAX_GLYPHS * (self.gpu.atlas_side() / ATLAS_SIZE).pow(2) as usize | |
| 555 | 647 | } |
| 556 | 648 | |
| 557 | 649 | fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] { |
| 558 | let side = self.atlas_side() as f32; | |
| 650 | let side = self.gpu.atlas_side() as f32; | |
| 559 | 651 | [ |
| 560 | 652 | glyph.x as f32 / side, |
| 561 | 653 | glyph.y as f32 / side, |
| ... | ... | @@ -564,13 +656,15 @@ impl Renderer { |
| 564 | 656 | ] |
| 565 | 657 | } |
| 566 | 658 | |
| 567 | /// The atlas's first texel, the white that solid fills sample. | |
| 659 | /// The atlas's white texel. | |
| 568 | 660 | fn white(&self) -> [f32; 4] { |
| 569 | [0.5 / self.atlas_side() as f32; 4] | |
| 661 | [0.5 / self.gpu.atlas_side() as f32; 4] | |
| 570 | 662 | } |
| 571 | 663 | |
| 664 | /// Empties the atlas but for its first texel, the white that solid fills sample. | |
| 572 | 665 | pub fn clear_glyph_cache(&mut self) { |
| 573 | 666 | self.glyphs.clear(); |
| 667 | self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | |
| 574 | 668 | self.pen = [1, 0]; |
| 575 | 669 | self.row_height = 1; |
| 576 | 670 | } |
| ... | ... | @@ -593,12 +687,12 @@ impl Renderer { |
| 593 | 687 | images: self.images.len(), |
| 594 | 688 | image_bytes, |
| 595 | 689 | vertices: self.vertices.len(), |
| 596 | batches: self.batches.len(), | |
| 690 | batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::<usize>(), | |
| 597 | 691 | vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(), |
| 598 | 692 | batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(), |
| 599 | 693 | glyph_capacity: self.glyphs.capacity(), |
| 600 | 694 | image_capacity: self.images.capacity(), |
| 601 | atlas_bytes: u64::from(self.atlas_side()).pow(2) * 4, | |
| 695 | atlas_bytes: u64::from(self.gpu.atlas_side()).pow(2) * 4, | |
| 602 | 696 | vertex_buffer_bytes: VERTEX_BUFFER_BYTES, |
| 603 | 697 | within_budget: self.glyphs.len() <= self.glyph_limit() |
| 604 | 698 | && self.images.len() <= MAX_IMAGES |
| ... | ... | @@ -633,16 +727,9 @@ impl Renderer { |
| 633 | 727 | let mut active_images = HashSet::new(); |
| 634 | 728 | let mut image_bytes = 0; |
| 635 | 729 | for layer in layers { |
| 636 | let space = Space { | |
| 637 | size, | |
| 638 | scale: layer.scale, | |
| 639 | origin: layer.origin, | |
| 640 | backdrop: layer.backdrop, | |
| 641 | round: layer.round, | |
| 642 | }; | |
| 643 | 730 | for primitive in layer.primitives { |
| 644 | 731 | if let Primitive::Image { image, rect } = primitive |
| 645 | && space.visible_rect(*rect)?.is_some() | |
| 732 | && layer.space(size).visible_rect(*rect)?.is_some() | |
| 646 | 733 | && active_images.insert(image.id()) |
| 647 | 734 | { |
| 648 | 735 | image_bytes += image.pixels().len() as u64; |
| ... | ... | @@ -668,15 +755,16 @@ impl Renderer { |
| 668 | 755 | }; |
| 669 | 756 | // Once cleared, the atlas holds only this frame's entries; past half full, the |
| 670 | 757 | // next frames would clear it again and again. |
| 671 | let crowded = full || (cleared && 2 * self.pen[1] > self.atlas_side()); | |
| 758 | let crowded = full || (cleared && 2 * self.pen[1] > self.gpu.atlas_side()); | |
| 672 | 759 | if !crowded { |
| 673 | 760 | break; |
| 674 | 761 | } |
| 675 | 762 | if !cleared { |
| 676 | 763 | self.clear_glyph_cache(); |
| 677 | 764 | cleared = true; |
| 678 | } else if self.atlas_side() * 2 <= limit { | |
| 679 | self.grow_atlas(); | |
| 765 | } else if self.gpu.atlas_side() * 2 <= limit { | |
| 766 | self.gpu.new_atlas(self.gpu.atlas_side() * 2); | |
| 767 | self.clear_glyph_cache(); | |
| 680 | 768 | } else if full { |
| 681 | 769 | return Err(RenderError::AtlasFull); |
| 682 | 770 | } else { |
| ... | ... | @@ -685,8 +773,8 @@ impl Renderer { |
| 685 | 773 | } |
| 686 | 774 | let frame = Frame { |
| 687 | 775 | vertices: &self.vertices, |
| 688 | batches: &self.batches, | |
| 689 | 776 | groups: &self.groups, |
| 777 | batches: &self.batches, | |
| 690 | 778 | images: &self.images, |
| 691 | 779 | }; |
| 692 | 780 | self.gpu.submit(&frame, target, size, clear); |
| ... | ... | @@ -713,13 +801,7 @@ impl Renderer { |
| 713 | 801 | self.barrier = self.batches.len(); |
| 714 | 802 | group = motion.map(|motion| (motion, self.batches.len())); |
| 715 | 803 | } |
| 716 | let space = Space { | |
| 717 | size, | |
| 718 | scale: layer.scale, | |
| 719 | origin: layer.origin, | |
| 720 | backdrop: layer.backdrop, | |
| 721 | round: layer.round, | |
| 722 | }; | |
| 804 | let space = layer.space(size); | |
| 723 | 805 | let bounds = match layer.clip { |
| 724 | 806 | Some(clip) => space.scissor(clip), |
| 725 | 807 | None => [0, 0, size[0], size[1]], |
| ... | ... | @@ -737,12 +819,11 @@ impl Renderer { |
| 737 | 819 | Ok(()) |
| 738 | 820 | } |
| 739 | 821 | |
| 740 | /// Makes the batches from `start` a group appearing by `motion`: the vertices that | |
| 741 | /// paint its offscreen picture over the target, in strips so the turn stays in | |
| 742 | /// perspective across the picture. | |
| 822 | /// Makes the batches from `start` a group appearing by `motion`, leaving in their place | |
| 823 | /// the batch that paints its offscreen picture over the target, in strips so the turn | |
| 824 | /// stays in perspective across the picture. | |
| 743 | 825 | fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { |
| 744 | let batches = start..self.batches.len(); | |
| 745 | if batches.is_empty() { | |
| 826 | if start == self.batches.len() { | |
| 746 | 827 | return Ok(()); |
| 747 | 828 | } |
| 748 | 829 | let [width, height] = size.map(|side| side as f32); |
| ... | ... | @@ -771,12 +852,7 @@ impl Renderer { |
| 771 | 852 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], |
| 772 | 853 | uv: [x / width, if flipped { 1.0 - v } else { v }], |
| 773 | 854 | color: [motion.opacity; 4], |
| 774 | local: [0.0; 2], | |
| 775 | shape: [0.0; 4], | |
| 776 | stroke: 0.0, | |
| 777 | clip_local: [0.0; 2], | |
| 778 | clip: [0.0; 3], | |
| 779 | blur: 0.0, | |
| 855 | ..Vertex::zeroed() | |
| 780 | 856 | } |
| 781 | 857 | }; |
| 782 | 858 | for strip in 0..strips { |
| ... | ... | @@ -790,10 +866,13 @@ impl Renderer { |
| 790 | 866 | ]; |
| 791 | 867 | self.vertices.extend([a, b, c, a, c, d]); |
| 792 | 868 | } |
| 793 | self.groups.push(Group { | |
| 794 | batches, | |
| 795 | composite: from..self.vertices.len() as u32, | |
| 869 | let batches = self.batches.split_off(start); | |
| 870 | self.batches.push(Batch { | |
| 871 | vertices: from..self.vertices.len() as u32, | |
| 872 | blend: Blend::Group(self.groups.len()), | |
| 873 | scissor: [0, 0, size[0], size[1]], | |
| 796 | 874 | }); |
| 875 | self.groups.push(batches); | |
| 797 | 876 | Ok(()) |
| 798 | 877 | } |
| 799 | 878 | |
| ... | ... | @@ -815,10 +894,7 @@ impl Renderer { |
| 815 | 894 | tint, |
| 816 | 895 | palette, |
| 817 | 896 | } => { |
| 818 | let origin = [ | |
| 819 | space.origin[0] + origin[0] * space.scale, | |
| 820 | space.origin[1] + origin[1] * space.scale, | |
| 821 | ]; | |
| 897 | let origin = space.point(*origin); | |
| 822 | 898 | self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?; |
| 823 | 899 | } |
| 824 | 900 | Primitive::Path { |
| ... | ... | @@ -838,10 +914,7 @@ impl Renderer { |
| 838 | 914 | clip, |
| 839 | 915 | ink, |
| 840 | 916 | } => { |
| 841 | let origin = [ | |
| 842 | space.origin[0] + origin[0] * space.scale, | |
| 843 | space.origin[1] + origin[1] * space.scale, | |
| 844 | ]; | |
| 917 | let origin = space.point(*origin); | |
| 845 | 918 | if origin.iter().any(|v| !v.is_finite()) { |
| 846 | 919 | return Err(RenderError::InvalidPrimitive); |
| 847 | 920 | } |
| ... | ... | @@ -858,17 +931,7 @@ impl Renderer { |
| 858 | 931 | text.runs(&mut |run| self.glyph_run(space, run, *ink))?; |
| 859 | 932 | } |
| 860 | 933 | Primitive::Rect { rect, color } => { |
| 861 | self.quad( | |
| 862 | space, | |
| 863 | [ | |
| 864 | rect[0] * space.scale + space.origin[0], | |
| 865 | rect[1] * space.scale + space.origin[1], | |
| 866 | rect[2] * space.scale + space.origin[0], | |
| 867 | rect[3] * space.scale + space.origin[1], | |
| 868 | ], | |
| 869 | self.white(), | |
| 870 | *color, | |
| 871 | )?; | |
| 934 | self.quad(space, space.rect(*rect), self.white(), *color)?; | |
| 872 | 935 | } |
| 873 | 936 | Primitive::RoundedRect { |
| 874 | 937 | rect, |
| ... | ... | @@ -993,7 +1056,7 @@ impl Renderer { |
| 993 | 1056 | return Ok(()); |
| 994 | 1057 | } |
| 995 | 1058 | let size = run.size * scale; |
| 996 | if !size.is_finite() || size > self.atlas_side() as f32 { | |
| 1059 | if !size.is_finite() || size > self.gpu.atlas_side() as f32 { | |
| 997 | 1060 | return Err(RenderError::AtlasFull); |
| 998 | 1061 | } |
| 999 | 1062 | let color = run |
| ... | ... | @@ -1119,7 +1182,7 @@ impl Renderer { |
| 1119 | 1182 | |
| 1120 | 1183 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { |
| 1121 | 1184 | let p = image.placement; |
| 1122 | let side = self.atlas_side(); | |
| 1185 | let side = self.gpu.atlas_side(); | |
| 1123 | 1186 | if p.width + 2 > side || p.height + 2 > side { |
| 1124 | 1187 | return Err(RenderError::AtlasFull); |
| 1125 | 1188 | } |
| ... | ... | @@ -1173,7 +1236,7 @@ impl Renderer { |
| 1173 | 1236 | { |
| 1174 | 1237 | return Ok(()); |
| 1175 | 1238 | } |
| 1176 | if size + 2.0 > self.atlas_side() as f32 { | |
| 1239 | if size + 2.0 > self.gpu.atlas_side() as f32 { | |
| 1177 | 1240 | return Err(RenderError::AtlasFull); |
| 1178 | 1241 | } |
| 1179 | 1242 | let size = size as u32; |
| ... | ... | @@ -1226,12 +1289,7 @@ impl Renderer { |
| 1226 | 1289 | width |
| 1227 | 1290 | } |
| 1228 | 1291 | }; |
| 1229 | let rect = [ | |
| 1230 | rect[0] * space.scale + space.origin[0], | |
| 1231 | rect[1] * space.scale + space.origin[1], | |
| 1232 | rect[2] * space.scale + space.origin[0], | |
| 1233 | rect[3] * space.scale + space.origin[1], | |
| 1234 | ]; | |
| 1292 | let rect = space.rect(rect); | |
| 1235 | 1293 | let start = self.vertices.len(); |
| 1236 | 1294 | // A pixel of margin holds the antialiased fringe outside the edge. |
| 1237 | 1295 | let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0]; |
| ... | ... | @@ -1273,7 +1331,7 @@ impl Renderer { |
| 1273 | 1331 | if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 { |
| 1274 | 1332 | return Err(RenderError::InvalidPrimitive); |
| 1275 | 1333 | } |
| 1276 | let rect = [0, 1, 2, 3].map(|i| rect[i] * space.scale + space.origin[i % 2]); | |
| 1334 | let rect = space.rect(rect); | |
| 1277 | 1335 | let sigma = blur * space.scale / 2.0; |
| 1278 | 1336 | let reach = 3.0 * sigma; |
| 1279 | 1337 | let start = self.vertices.len(); |
| ... | ... | @@ -1305,8 +1363,9 @@ impl Renderer { |
| 1305 | 1363 | style: PathStyle, |
| 1306 | 1364 | colors: [[f32; 4]; 2], |
| 1307 | 1365 | ) -> Result<(), RenderError> { |
| 1308 | let [x, y] = [0, 1] | |
| 1309 | .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); | |
| 1366 | let [x, y] = space | |
| 1367 | .point(origin) | |
| 1368 | .map(|value| (value * 4.0).round() * 0.25); | |
| 1310 | 1369 | let (kind, width) = match style { |
| 1311 | 1370 | PathStyle::Fill | PathStyle::Erase => (0, 0.0), |
| 1312 | 1371 | PathStyle::Stroke(width) => (1, width), |
| ... | ... | @@ -1400,13 +1459,7 @@ impl Renderer { |
| 1400 | 1459 | round: bool, |
| 1401 | 1460 | color: [f32; 4], |
| 1402 | 1461 | ) -> Result<(), RenderError> { |
| 1403 | let pixel = |p: [f32; 2]| { | |
| 1404 | [ | |
| 1405 | p[0] * space.scale + space.origin[0], | |
| 1406 | p[1] * space.scale + space.origin[1], | |
| 1407 | ] | |
| 1408 | }; | |
| 1409 | let [from, to] = [pixel(from), pixel(to)]; | |
| 1462 | let [from, to] = [space.point(from), space.point(to)]; | |
| 1410 | 1463 | // Hairlines stay one device pixel wide. |
| 1411 | 1464 | let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5); |
| 1412 | 1465 | let radius = radii[0].max(radii[1]); |
| ... | ... | @@ -1463,14 +1516,12 @@ impl Renderer { |
| 1463 | 1516 | x * 2.0 / space.size[0] as f32 - 1.0, |
| 1464 | 1517 | 1.0 - y * 2.0 / space.size[1] as f32, |
| 1465 | 1518 | ], |
| 1466 | uv: [0.5 / self.atlas_side() as f32; 2], | |
| 1519 | uv: [self.white()[0]; 2], | |
| 1467 | 1520 | color, |
| 1468 | 1521 | local, |
| 1469 | 1522 | shape, |
| 1470 | stroke: 0.0, | |
| 1471 | clip_local: [0.0; 2], | |
| 1472 | clip: [0.0; 3], | |
| 1473 | 1523 | blur, |
| 1524 | ..Vertex::zeroed() | |
| 1474 | 1525 | }); |
| 1475 | 1526 | } |
| 1476 | 1527 | Ok(()) |
| ... | ... | @@ -1520,11 +1571,7 @@ impl Renderer { |
| 1520 | 1571 | uv, |
| 1521 | 1572 | color, |
| 1522 | 1573 | local, |
| 1523 | shape: [0.0; 4], | |
| 1524 | stroke: 0.0, | |
| 1525 | clip_local: [0.0; 2], | |
| 1526 | clip: [0.0; 3], | |
| 1527 | blur: 0.0, | |
| 1574 | ..Vertex::zeroed() | |
| 1528 | 1575 | }); |
| 1529 | 1576 | } |
| 1530 | 1577 | Ok(()) |
| ... | ... | @@ -1585,12 +1632,7 @@ fn soften( |
| 1585 | 1632 | } |
| 1586 | 1633 | |
| 1587 | 1634 | fn srgb_byte(value: f32) -> u8 { |
| 1588 | let srgb = if value <= 0.0031308 { | |
| 1589 | value * 12.92 | |
| 1590 | } else { | |
| 1591 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 1592 | }; | |
| 1593 | (srgb * 255.0).round() as u8 | |
| 1635 | (crate::encode(value) * 255.0).round() as u8 | |
| 1594 | 1636 | } |
| 1595 | 1637 | |
| 1596 | 1638 | /// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light, |
| ... | ... | @@ -1599,11 +1641,11 @@ fn srgb_byte(value: f32) -> u8 { |
| 1599 | 1641 | /// nearly untouched. |
| 1600 | 1642 | fn text_coverage(tone: u8) -> [u8; 256] { |
| 1601 | 1643 | let encoded = f32::from(tone) / 16.0; |
| 1602 | let ink = linear(encoded); | |
| 1644 | let ink = crate::linear(encoded); | |
| 1603 | 1645 | std::array::from_fn(|coverage| { |
| 1604 | 1646 | let coverage = coverage as f32 / 255.0; |
| 1605 | 1647 | let weighted = if ink < 0.999 { |
| 1606 | (1.0 - linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink) | |
| 1648 | (1.0 - crate::linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink) | |
| 1607 | 1649 | } else { |
| 1608 | 1650 | coverage |
| 1609 | 1651 | }; |
| ... | ... | @@ -1611,14 +1653,6 @@ fn text_coverage(tone: u8) -> [u8; 256] { |
| 1611 | 1653 | }) |
| 1612 | 1654 | } |
| 1613 | 1655 | |
| 1614 | fn linear(value: f32) -> f32 { | |
| 1615 | if value <= 0.04045 { | |
| 1616 | value / 12.92 | |
| 1617 | } else { | |
| 1618 | ((value + 0.055) / 1.055).powf(2.4) | |
| 1619 | } | |
| 1620 | } | |
| 1621 | ||
| 1622 | 1656 | /// Least OKLab lightness difference between text and the backdrop it stays legible on. |
| 1623 | 1657 | const LEGIBLE: f32 = 0.4; |
| 1624 | 1658 | |
| ... | ... | @@ -1670,7 +1704,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { |
| 1670 | 1704 | |
| 1671 | 1705 | /// Linear RGBA of an opaque sRGB colour. |
| 1672 | 1706 | pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { |
| 1673 | let [red, green, blue] = [red, green, blue].map(|byte| linear(f32::from(byte) / 255.0)); | |
| 1707 | let [red, green, blue] = [red, green, blue].map(|byte| crate::linear(f32::from(byte) / 255.0)); | |
| 1674 | 1708 | [red, green, blue, 1.0] |
| 1675 | 1709 | } |
| 1676 | 1710 | |
| ... | ... | @@ -1681,18 +1715,18 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { |
| 1681 | 1715 | |
| 1682 | 1716 | /// The hue in degrees of a linear colour, as it looks in sRGB. |
| 1683 | 1717 | pub fn hue(color: [f32; 4]) -> f32 { |
| 1684 | let [r, g, b] = [color[0], color[1], color[2]].map(|value| { | |
| 1685 | if value <= 0.003_130_8 { | |
| 1686 | value * 12.92 | |
| 1687 | } else { | |
| 1688 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 1689 | } | |
| 1690 | }); | |
| 1691 | let max = r.max(g).max(b); | |
| 1692 | let range = max - r.min(g).min(b); | |
| 1718 | to_hsl([color[0], color[1], color[2]].map(crate::encode))[0] | |
| 1719 | } | |
| 1720 | ||
| 1721 | /// Hue in degrees, saturation and lightness of an encoded sRGB colour. | |
| 1722 | fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] { | |
| 1723 | let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; | |
| 1724 | let lightness = (max + min) / 2.0; | |
| 1725 | let range = max - min; | |
| 1693 | 1726 | if range == 0.0 { |
| 1694 | return 0.0; | |
| 1727 | return [0.0, 0.0, lightness]; | |
| 1695 | 1728 | } |
| 1729 | let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs()); | |
| 1696 | 1730 | let sector = if max == r { |
| 1697 | 1731 | (g - b) / range |
| 1698 | 1732 | } else if max == g { |
| ... | ... | @@ -1700,7 +1734,16 @@ pub fn hue(color: [f32; 4]) -> f32 { |
| 1700 | 1734 | } else { |
| 1701 | 1735 | (r - g) / range + 4.0 |
| 1702 | 1736 | }; |
| 1703 | (sector * 60.0).rem_euclid(360.0) | |
| 1737 | [(sector * 60.0).rem_euclid(360.0), saturation, lightness] | |
| 1738 | } | |
| 1739 | ||
| 1740 | /// The encoded sRGB colour of a hue in degrees, saturation and lightness. | |
| 1741 | fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] { | |
| 1742 | let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; | |
| 1743 | [0.0, 8.0, 4.0].map(|offset: f32| { | |
| 1744 | let k = (offset + hue / 30.0).rem_euclid(12.0); | |
| 1745 | lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) | |
| 1746 | }) | |
| 1704 | 1747 | } |
| 1705 | 1748 | |
| 1706 | 1749 | /// The light theme's accent `[saturation, lightness]`, also the default ink of a section's pen. |
| ... | ... | @@ -1708,13 +1751,9 @@ pub const LIGHT_ACCENT: [f32; 2] = [0.60, 0.45]; |
| 1708 | 1751 | |
| 1709 | 1752 | /// An sRGB hue, saturation and lightness as linear RGBA. |
| 1710 | 1753 | pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] { |
| 1711 | let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; | |
| 1712 | let channel = |offset: f32| { | |
| 1713 | let k = (offset + hue / 30.0).rem_euclid(12.0); | |
| 1714 | let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0); | |
| 1715 | (value * 255.0).round().clamp(0.0, 255.0) as u8 | |
| 1716 | }; | |
| 1717 | srgb(channel(0.0), channel(8.0), channel(4.0)) | |
| 1754 | let [red, green, blue] = from_hsl([hue, saturation, lightness]) | |
| 1755 | .map(|value| (value * 255.0).round().clamp(0.0, 255.0) as u8); | |
| 1756 | srgb(red, green, blue) | |
| 1718 | 1757 | } |
| 1719 | 1758 | |
| 1720 | 1759 | #[cfg(all(test, feature = "wgpu"))] |
| ... | ... | @@ -2547,13 +2586,13 @@ mod tests { |
| 2547 | 2586 | target.draw(&mut renderer, &page(&primitives)).unwrap(); |
| 2548 | 2587 | } |
| 2549 | 2588 | assert_eq!( |
| 2550 | renderer.atlas_side(), | |
| 2589 | renderer.gpu.atlas_side(), | |
| 2551 | 2590 | ATLAS_SIZE, |
| 2552 | 2591 | "frames each needing a third of the atlas evict, never grow it" |
| 2553 | 2592 | ); |
| 2554 | 2593 | let primitives = icons(0..3 * one_atlas); |
| 2555 | 2594 | target.draw(&mut renderer, &page(&primitives)).unwrap(); |
| 2556 | assert!(renderer.atlas_side() > ATLAS_SIZE); | |
| 2595 | assert!(renderer.gpu.atlas_side() > ATLAS_SIZE); | |
| 2557 | 2596 | assert!(renderer.glyphs.len() >= 3 * one_atlas); |
| 2558 | 2597 | let capture = target.capture(&renderer); |
| 2559 | 2598 | let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; |
crates/draw/src/render/d3d11.rs created+585| ... | ... | @@ -0,0 +1,585 @@ |
| 1 | //! Submission through Direct3D 11 from feature level 10_0, or WARP where no device has it: | |
| 2 | //! the Direct3D Windows 7 has. Frames draw into an sRGB texture, copied to a DXGI 1.1 swap | |
| 3 | //! chain that blits into the window's redirection surface, as Windows 7 has no flip model, | |
| 4 | //! or where the window has none, to a flip-model one that DirectComposition shows. | |
| 5 | use super::*; | |
| 6 | use std::{ffi::c_void, ptr}; | |
| 7 | use windows::{ | |
| 8 | Win32::{ | |
| 9 | Foundation::{HMODULE, HWND, RECT}, | |
| 10 | Graphics::{ | |
| 11 | Direct3D::*, | |
| 12 | Direct3D11::*, | |
| 13 | DirectComposition::{IDCompositionDevice, IDCompositionTarget, IDCompositionVisual}, | |
| 14 | Dxgi::{Common::*, *}, | |
| 15 | }, | |
| 16 | System::LibraryLoader::{GetProcAddress, LoadLibraryW}, | |
| 17 | UI::WindowsAndMessaging::{ | |
| 18 | GWL_EXSTYLE, GetClientRect, GetWindowLongPtrW, WS_EX_NOREDIRECTIONBITMAP, | |
| 19 | }, | |
| 20 | }, | |
| 21 | core::{Error, GUID, HRESULT, Interface, PCSTR, Result, s, w}, | |
| 22 | }; | |
| 23 | ||
| 24 | // Shader model 4.0 bytecode from `dxbc/compile.ps1`: the build host has no HLSL compiler, and | |
| 25 | // Windows 7 has a runtime one only with an optional update. | |
| 26 | const DRAW_VERTEX: &[u8] = include_bytes!("dxbc/draw.vertex.dxbc"); | |
| 27 | const DRAW_FRAGMENT: &[u8] = include_bytes!("dxbc/draw.fragment.dxbc"); | |
| 28 | const TRANSLUCENT_VERTEX: &[u8] = include_bytes!("dxbc/translucent.vertex.dxbc"); | |
| 29 | const TRANSLUCENT_FRAGMENT: &[u8] = include_bytes!("dxbc/translucent.fragment.dxbc"); | |
| 30 | ||
| 31 | const FORMAT: DXGI_FORMAT = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; | |
| 32 | ||
| 33 | /// What a successful creating call leaves in its out-parameter. | |
| 34 | fn made<T>(create: impl FnOnce(&mut Option<T>) -> Result<()>) -> Result<T> { | |
| 35 | let mut made = None; | |
| 36 | create(&mut made)?; | |
| 37 | Ok(made.expect("A successful call returns what it made")) | |
| 38 | } | |
| 39 | ||
| 40 | /// An sRGB RGBA texture, `pixels` its rows if given. | |
| 41 | fn texture( | |
| 42 | device: &ID3D11Device, | |
| 43 | size: [u32; 2], | |
| 44 | bind: D3D11_BIND_FLAG, | |
| 45 | pixels: Option<&[u8]>, | |
| 46 | ) -> Result<ID3D11Texture2D> { | |
| 47 | let description = D3D11_TEXTURE2D_DESC { | |
| 48 | Width: size[0], | |
| 49 | Height: size[1], | |
| 50 | MipLevels: 1, | |
| 51 | ArraySize: 1, | |
| 52 | Format: FORMAT, | |
| 53 | SampleDesc: DXGI_SAMPLE_DESC { | |
| 54 | Count: 1, | |
| 55 | Quality: 0, | |
| 56 | }, | |
| 57 | Usage: D3D11_USAGE_DEFAULT, | |
| 58 | BindFlags: bind.0 as u32, | |
| 59 | ..Default::default() | |
| 60 | }; | |
| 61 | let data = pixels.map(|pixels| D3D11_SUBRESOURCE_DATA { | |
| 62 | pSysMem: pixels.as_ptr().cast(), | |
| 63 | SysMemPitch: size[0] * 4, | |
| 64 | SysMemSlicePitch: 0, | |
| 65 | }); | |
| 66 | made(|out| unsafe { | |
| 67 | device.CreateTexture2D(&description, data.as_ref().map(ptr::from_ref), Some(out)) | |
| 68 | }) | |
| 69 | } | |
| 70 | ||
| 71 | fn description(texture: &ID3D11Texture2D) -> D3D11_TEXTURE2D_DESC { | |
| 72 | let mut description = D3D11_TEXTURE2D_DESC::default(); | |
| 73 | unsafe { texture.GetDesc(&mut description) }; | |
| 74 | description | |
| 75 | } | |
| 76 | ||
| 77 | fn resource(device: &ID3D11Device, texture: &ID3D11Texture2D) -> Result<ID3D11ShaderResourceView> { | |
| 78 | made(|out| unsafe { device.CreateShaderResourceView(texture, None, Some(out)) }) | |
| 79 | } | |
| 80 | ||
| 81 | /// A texture as the fragment shader samples it, or none where Direct3D made none, as when | |
| 82 | /// the device is lost. | |
| 83 | pub(super) struct Image(Option<ID3D11ShaderResourceView>); | |
| 84 | ||
| 85 | /// An offscreen frame, which `Renderer::present` shows in the window. | |
| 86 | pub struct Target { | |
| 87 | texture: ID3D11Texture2D, | |
| 88 | view: ID3D11RenderTargetView, | |
| 89 | resource: ID3D11ShaderResourceView, | |
| 90 | } | |
| 91 | ||
| 92 | impl Target { | |
| 93 | fn new(device: &ID3D11Device, size: [u32; 2]) -> Result<Self> { | |
| 94 | let bind = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; | |
| 95 | let texture = texture(device, size, bind, None)?; | |
| 96 | let view = made(|out| unsafe { device.CreateRenderTargetView(&texture, None, Some(out)) })?; | |
| 97 | let resource = resource(device, &texture)?; | |
| 98 | Ok(Self { | |
| 99 | texture, | |
| 100 | view, | |
| 101 | resource, | |
| 102 | }) | |
| 103 | } | |
| 104 | ||
| 105 | pub fn size(&self) -> [u32; 2] { | |
| 106 | let description = description(&self.texture); | |
| 107 | [description.Width, description.Height] | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | pub(super) struct Gpu { | |
| 112 | device: ID3D11Device, | |
| 113 | context: ID3D11DeviceContext, | |
| 114 | swap_chain: IDXGISwapChain, | |
| 115 | /// What shows the swap chain in a window without a redirection surface. | |
| 116 | _composition: Option<Composition>, | |
| 117 | layout: ID3D11InputLayout, | |
| 118 | vertex: ID3D11VertexShader, | |
| 119 | fragment: ID3D11PixelShader, | |
| 120 | translucent: (ID3D11VertexShader, ID3D11PixelShader), | |
| 121 | /// One for each of `Blend::STATES`. | |
| 122 | blends: [ID3D11BlendState; 4], | |
| 123 | rasterizer: ID3D11RasterizerState, | |
| 124 | nearest: ID3D11SamplerState, | |
| 125 | linear: ID3D11SamplerState, | |
| 126 | buffer: ID3D11Buffer, | |
| 127 | atlas: (ID3D11Texture2D, ID3D11ShaderResourceView), | |
| 128 | /// Offscreen pictures for groups, the target's size. | |
| 129 | groups: Vec<Target>, | |
| 130 | } | |
| 131 | ||
| 132 | impl Gpu { | |
| 133 | /// A device and a swap chain on `window`, an `HWND`, and the adapter's description. | |
| 134 | pub(super) fn new(window: *mut c_void) -> Result<(Self, String)> { | |
| 135 | let levels = [ | |
| 136 | D3D_FEATURE_LEVEL_11_0, | |
| 137 | D3D_FEATURE_LEVEL_10_1, | |
| 138 | D3D_FEATURE_LEVEL_10_0, | |
| 139 | ]; | |
| 140 | let create = |driver| { | |
| 141 | let (mut device, mut context) = (None, None); | |
| 142 | unsafe { | |
| 143 | D3D11CreateDevice( | |
| 144 | None, | |
| 145 | driver, | |
| 146 | HMODULE::default(), | |
| 147 | D3D11_CREATE_DEVICE_FLAG(0), | |
| 148 | Some(&levels), | |
| 149 | D3D11_SDK_VERSION, | |
| 150 | Some(&mut device), | |
| 151 | None, | |
| 152 | Some(&mut context), | |
| 153 | ) | |
| 154 | } | |
| 155 | .map(|()| (device.expect("A device"), context.expect("A context"))) | |
| 156 | }; | |
| 157 | let (device, context) = | |
| 158 | create(D3D_DRIVER_TYPE_HARDWARE).or_else(|_| create(D3D_DRIVER_TYPE_WARP))?; | |
| 159 | let adapter = unsafe { device.cast::<IDXGIDevice>()?.GetAdapter()? }; | |
| 160 | let name = unsafe { adapter.GetDesc()? }.Description; | |
| 161 | let name = String::from_utf16_lossy(name.split(|&c| c == 0).next().unwrap_or(&[])); | |
| 162 | let level = unsafe { device.GetFeatureLevel() }.0; | |
| 163 | let [major, minor] = [level >> 12, (level >> 8) & 0xf]; | |
| 164 | let description = format!("{name}, feature level {major}_{minor}"); | |
| 165 | let hwnd = HWND(window); | |
| 166 | let factory: IDXGIFactory = unsafe { adapter.GetParent()? }; | |
| 167 | // Windows 10's window has no redirection surface, made for wgpu's DirectComposition. | |
| 168 | let style = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) } as u32; | |
| 169 | let (swap_chain, composition) = if style & WS_EX_NOREDIRECTIONBITMAP.0 != 0 { | |
| 170 | let (swap_chain, composition) = compose(&device, &factory, hwnd)?; | |
| 171 | (swap_chain, Some(composition)) | |
| 172 | } else { | |
| 173 | let description = DXGI_SWAP_CHAIN_DESC { | |
| 174 | // Windows 7's desktop composes a blit swap chain's alpha only from BGRA. | |
| 175 | BufferDesc: DXGI_MODE_DESC { | |
| 176 | Format: DXGI_FORMAT_B8G8R8A8_UNORM, | |
| 177 | ..Default::default() | |
| 178 | }, | |
| 179 | SampleDesc: DXGI_SAMPLE_DESC { | |
| 180 | Count: 1, | |
| 181 | Quality: 0, | |
| 182 | }, | |
| 183 | BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, | |
| 184 | BufferCount: 1, | |
| 185 | OutputWindow: hwnd, | |
| 186 | Windowed: true.into(), | |
| 187 | SwapEffect: DXGI_SWAP_EFFECT_DISCARD, | |
| 188 | Flags: 0, | |
| 189 | }; | |
| 190 | let swap_chain = | |
| 191 | made(|out| unsafe { factory.CreateSwapChain(&device, &description, out).ok() })?; | |
| 192 | (swap_chain, None) | |
| 193 | }; | |
| 194 | // DXGI would otherwise watch the window's messages and take Alt+Enter to full screen. | |
| 195 | unsafe { | |
| 196 | factory | |
| 197 | .MakeWindowAssociation(hwnd, DXGI_MWA_NO_WINDOW_CHANGES | DXGI_MWA_NO_ALT_ENTER)?; | |
| 198 | } | |
| 199 | ||
| 200 | let elements: [_; 9] = std::array::from_fn(|index| { | |
| 201 | let (_, floats, offset) = ATTRIBUTES[index]; | |
| 202 | D3D11_INPUT_ELEMENT_DESC { | |
| 203 | SemanticName: PCSTR(c"TEXCOORD".as_ptr().cast()), | |
| 204 | SemanticIndex: index as u32, | |
| 205 | Format: [ | |
| 206 | DXGI_FORMAT_R32_FLOAT, | |
| 207 | DXGI_FORMAT_R32G32_FLOAT, | |
| 208 | DXGI_FORMAT_R32G32B32_FLOAT, | |
| 209 | DXGI_FORMAT_R32G32B32A32_FLOAT, | |
| 210 | ][floats - 1], | |
| 211 | InputSlot: 0, | |
| 212 | AlignedByteOffset: offset as u32, | |
| 213 | InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, | |
| 214 | InstanceDataStepRate: 0, | |
| 215 | } | |
| 216 | }); | |
| 217 | let layout = | |
| 218 | made(|out| unsafe { device.CreateInputLayout(&elements, DRAW_VERTEX, Some(out)) })?; | |
| 219 | let vertex_shader = | |
| 220 | |code| made(|out| unsafe { device.CreateVertexShader(code, None, Some(out)) }); | |
| 221 | let pixel_shader = | |
| 222 | |code| made(|out| unsafe { device.CreatePixelShader(code, None, Some(out)) }); | |
| 223 | ||
| 224 | let blend = |factors: [Factor; 4]| { | |
| 225 | let [src, dst, src_alpha, dst_alpha] = factors.map(|factor| match factor { | |
| 226 | Factor::Zero => D3D11_BLEND_ZERO, | |
| 227 | Factor::One => D3D11_BLEND_ONE, | |
| 228 | Factor::SourceAlpha => D3D11_BLEND_SRC_ALPHA, | |
| 229 | Factor::OneMinusSourceAlpha => D3D11_BLEND_INV_SRC_ALPHA, | |
| 230 | Factor::Destination => D3D11_BLEND_DEST_COLOR, | |
| 231 | }); | |
| 232 | let mut description = D3D11_BLEND_DESC::default(); | |
| 233 | description.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { | |
| 234 | BlendEnable: true.into(), | |
| 235 | SrcBlend: src, | |
| 236 | DestBlend: dst, | |
| 237 | BlendOp: D3D11_BLEND_OP_ADD, | |
| 238 | SrcBlendAlpha: src_alpha, | |
| 239 | DestBlendAlpha: dst_alpha, | |
| 240 | BlendOpAlpha: D3D11_BLEND_OP_ADD, | |
| 241 | RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, | |
| 242 | }; | |
| 243 | made(|out| unsafe { device.CreateBlendState(&description, Some(out)) }) | |
| 244 | }; | |
| 245 | let sampler = |filter| { | |
| 246 | let description = D3D11_SAMPLER_DESC { | |
| 247 | Filter: filter, | |
| 248 | AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, | |
| 249 | AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, | |
| 250 | AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, | |
| 251 | ComparisonFunc: D3D11_COMPARISON_NEVER, | |
| 252 | MaxLOD: f32::MAX, | |
| 253 | ..Default::default() | |
| 254 | }; | |
| 255 | made(|out| unsafe { device.CreateSamplerState(&description, Some(out)) }) | |
| 256 | }; | |
| 257 | let rasterizer = D3D11_RASTERIZER_DESC { | |
| 258 | FillMode: D3D11_FILL_SOLID, | |
| 259 | CullMode: D3D11_CULL_NONE, | |
| 260 | DepthClipEnable: true.into(), | |
| 261 | ScissorEnable: true.into(), | |
| 262 | ..Default::default() | |
| 263 | }; | |
| 264 | let buffer = D3D11_BUFFER_DESC { | |
| 265 | ByteWidth: VERTEX_BUFFER_BYTES as u32, | |
| 266 | Usage: D3D11_USAGE_DYNAMIC, | |
| 267 | BindFlags: D3D11_BIND_VERTEX_BUFFER.0 as u32, | |
| 268 | CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, | |
| 269 | ..Default::default() | |
| 270 | }; | |
| 271 | let [over, picture, erase, multiply] = Blend::STATES; | |
| 272 | let gpu = Self { | |
| 273 | layout, | |
| 274 | vertex: vertex_shader(DRAW_VERTEX)?, | |
| 275 | fragment: pixel_shader(DRAW_FRAGMENT)?, | |
| 276 | translucent: ( | |
| 277 | vertex_shader(TRANSLUCENT_VERTEX)?, | |
| 278 | pixel_shader(TRANSLUCENT_FRAGMENT)?, | |
| 279 | ), | |
| 280 | blends: [ | |
| 281 | blend(over)?, | |
| 282 | blend(picture)?, | |
| 283 | blend(erase)?, | |
| 284 | blend(multiply)?, | |
| 285 | ], | |
| 286 | rasterizer: made(|out| unsafe { | |
| 287 | device.CreateRasterizerState(&rasterizer, Some(out)) | |
| 288 | })?, | |
| 289 | nearest: sampler(D3D11_FILTER_MIN_MAG_MIP_POINT)?, | |
| 290 | linear: sampler(D3D11_FILTER_MIN_MAG_MIP_LINEAR)?, | |
| 291 | buffer: made(|out| unsafe { device.CreateBuffer(&buffer, None, Some(out)) })?, | |
| 292 | atlas: atlas(&device, ATLAS_SIZE)?, | |
| 293 | groups: Vec::new(), | |
| 294 | device, | |
| 295 | context, | |
| 296 | swap_chain, | |
| 297 | _composition: composition, | |
| 298 | }; | |
| 299 | Ok((gpu, description)) | |
| 300 | } | |
| 301 | ||
| 302 | pub(super) fn target(&self, size: [u32; 2]) -> Result<Target> { | |
| 303 | Target::new(&self.device, size) | |
| 304 | } | |
| 305 | ||
| 306 | /// Copies `target` to the window, premultiplying each pixel again in sRGB, as the | |
| 307 | /// desktop compositing the window over its backdrop needs, which leaves opaque ones be. | |
| 308 | pub(super) fn present(&self, target: &Target) -> Result<()> { | |
| 309 | let context = &self.context; | |
| 310 | let size = target.size(); | |
| 311 | let [width, height] = size; | |
| 312 | unsafe { | |
| 313 | let buffers = self.swap_chain.GetDesc()?.BufferDesc; | |
| 314 | if [buffers.Width, buffers.Height] != size { | |
| 315 | let flags = DXGI_SWAP_CHAIN_FLAG(0); | |
| 316 | (self.swap_chain).ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, flags)?; | |
| 317 | } | |
| 318 | let buffer: ID3D11Texture2D = self.swap_chain.GetBuffer(0)?; | |
| 319 | let view = made(|out| self.device.CreateRenderTargetView(&buffer, None, Some(out)))?; | |
| 320 | let (vertex, fragment) = &self.translucent; | |
| 321 | self.begin(&view, size); | |
| 322 | context.OMSetBlendState(None, None, u32::MAX); | |
| 323 | context.VSSetShader(vertex, None); | |
| 324 | context.PSSetShader(fragment, None); | |
| 325 | context.PSSetShaderResources(0, Some(&[Some(target.resource.clone())])); | |
| 326 | context.Draw(3, 0); | |
| 327 | context.PSSetShaderResources(0, Some(&[None])); | |
| 328 | context.OMSetRenderTargets(None, None); | |
| 329 | self.swap_chain.Present(1, DXGI_PRESENT(0)).ok() | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | /// sRGB-encoded RGBA rows of `target`, top first. | |
| 334 | pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>> { | |
| 335 | let mut description = description(&target.texture); | |
| 336 | let [width, height] = [description.Width, description.Height]; | |
| 337 | description.Usage = D3D11_USAGE_STAGING; | |
| 338 | description.BindFlags = 0; | |
| 339 | description.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; | |
| 340 | let row = width as usize * 4; | |
| 341 | let mut pixels = Vec::with_capacity(row * height as usize); | |
| 342 | unsafe { | |
| 343 | let staging = made(|out| self.device.CreateTexture2D(&description, None, Some(out)))?; | |
| 344 | self.context.CopyResource(&staging, &target.texture); | |
| 345 | let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); | |
| 346 | self.context | |
| 347 | .Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; | |
| 348 | for y in 0..height as usize { | |
| 349 | let start = mapped.pData.cast::<u8>().add(y * mapped.RowPitch as usize); | |
| 350 | pixels.extend_from_slice(std::slice::from_raw_parts(start, row)); | |
| 351 | } | |
| 352 | self.context.Unmap(&staging, 0); | |
| 353 | } | |
| 354 | Ok(pixels) | |
| 355 | } | |
| 356 | ||
| 357 | /// Offscreen pictures' rows run top first. | |
| 358 | pub(super) fn flipped(&self) -> bool { | |
| 359 | false | |
| 360 | } | |
| 361 | ||
| 362 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 363 | if unsafe { self.device.GetFeatureLevel() }.0 >= D3D_FEATURE_LEVEL_11_0.0 { | |
| 364 | D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION | |
| 365 | } else { | |
| 366 | // Feature level 10's. | |
| 367 | 8192 | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | pub(super) fn atlas_side(&self) -> u32 { | |
| 372 | description(&self.atlas.0).Width | |
| 373 | } | |
| 374 | ||
| 375 | pub(super) fn new_atlas(&mut self, side: u32) { | |
| 376 | match atlas(&self.device, side) { | |
| 377 | Ok(atlas) => self.atlas = atlas, | |
| 378 | Err(error) => eprintln!("No glyph atlas {side} pixels square: {error}"), | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 382 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | |
| 383 | let area = D3D11_BOX { | |
| 384 | left: origin[0], | |
| 385 | top: origin[1], | |
| 386 | front: 0, | |
| 387 | right: origin[0] + size[0], | |
| 388 | bottom: origin[1] + size[1], | |
| 389 | back: 1, | |
| 390 | }; | |
| 391 | unsafe { | |
| 392 | self.context.UpdateSubresource( | |
| 393 | &self.atlas.0, | |
| 394 | 0, | |
| 395 | Some(&area), | |
| 396 | rgba.as_ptr().cast(), | |
| 397 | size[0] * 4, | |
| 398 | 0, | |
| 399 | ); | |
| 400 | } | |
| 401 | } | |
| 402 | ||
| 403 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { | |
| 404 | let texture = texture( | |
| 405 | &self.device, | |
| 406 | image.size, | |
| 407 | D3D11_BIND_SHADER_RESOURCE, | |
| 408 | Some(image.pixels()), | |
| 409 | ); | |
| 410 | let view = texture.and_then(|texture| resource(&self.device, &texture)); | |
| 411 | Image( | |
| 412 | view.inspect_err(|error| eprintln!("No image texture: {error}")) | |
| 413 | .ok(), | |
| 414 | ) | |
| 415 | } | |
| 416 | ||
| 417 | /// Binds `view`, `size` pixels, as the render target, with the pipeline's state. | |
| 418 | fn begin(&self, view: &ID3D11RenderTargetView, size: [u32; 2]) { | |
| 419 | let context = &self.context; | |
| 420 | unsafe { | |
| 421 | context.PSSetShaderResources(0, Some(&[None])); | |
| 422 | context.OMSetRenderTargets(Some(&[Some(view.clone())]), None); | |
| 423 | context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); | |
| 424 | context.RSSetState(&self.rasterizer); | |
| 425 | context.RSSetViewports(Some(&[D3D11_VIEWPORT { | |
| 426 | TopLeftX: 0.0, | |
| 427 | TopLeftY: 0.0, | |
| 428 | Width: size[0] as f32, | |
| 429 | Height: size[1] as f32, | |
| 430 | MinDepth: 0.0, | |
| 431 | MaxDepth: 1.0, | |
| 432 | }])); | |
| 433 | context.RSSetScissorRects(Some(&[RECT { | |
| 434 | left: 0, | |
| 435 | top: 0, | |
| 436 | right: size[0] as i32, | |
| 437 | bottom: size[1] as i32, | |
| 438 | }])); | |
| 439 | } | |
| 440 | } | |
| 441 | ||
| 442 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | |
| 443 | /// batches, each group's offscreen first. | |
| 444 | pub(super) fn submit( | |
| 445 | &mut self, | |
| 446 | frame: &Frame<'_>, | |
| 447 | target: &Target, | |
| 448 | size: [u32; 2], | |
| 449 | clear: [f32; 4], | |
| 450 | ) { | |
| 451 | if self | |
| 452 | .groups | |
| 453 | .first() | |
| 454 | .is_some_and(|picture| picture.size() != size) | |
| 455 | { | |
| 456 | self.groups.clear(); | |
| 457 | } | |
| 458 | while self.groups.len() < frame.groups.len() { | |
| 459 | match Target::new(&self.device, size) { | |
| 460 | Ok(picture) => self.groups.push(picture), | |
| 461 | Err(error) => { | |
| 462 | eprintln!("No offscreen picture: {error}"); | |
| 463 | return; | |
| 464 | } | |
| 465 | } | |
| 466 | } | |
| 467 | let gpu = &*self; | |
| 468 | let context = &gpu.context; | |
| 469 | let bytes: &[u8] = bytemuck::cast_slice(frame.vertices); | |
| 470 | let bytes = &bytes[..bytes.len().min(VERTEX_BUFFER_BYTES as usize)]; | |
| 471 | unsafe { | |
| 472 | let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); | |
| 473 | if let Err(error) = context.Map( | |
| 474 | &gpu.buffer, | |
| 475 | 0, | |
| 476 | D3D11_MAP_WRITE_DISCARD, | |
| 477 | 0, | |
| 478 | Some(&mut mapped), | |
| 479 | ) { | |
| 480 | eprintln!("No vertex buffer: {error}"); | |
| 481 | return; | |
| 482 | } | |
| 483 | ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.pData.cast(), bytes.len()); | |
| 484 | context.Unmap(&gpu.buffer, 0); | |
| 485 | context.IASetInputLayout(&gpu.layout); | |
| 486 | let buffer = Some(gpu.buffer.clone()); | |
| 487 | let stride = size_of::<Vertex>() as u32; | |
| 488 | context.IASetVertexBuffers(0, 1, Some(&buffer), Some(&stride), Some(&0)); | |
| 489 | } | |
| 490 | let paint = |target: &Target, clear: [f32; 4], batches: &[Batch]| unsafe { | |
| 491 | gpu.begin(&target.view, size); | |
| 492 | context.VSSetShader(&gpu.vertex, None); | |
| 493 | context.PSSetShader(&gpu.fragment, None); | |
| 494 | context.ClearRenderTargetView(&target.view, &clear); | |
| 495 | for batch in batches { | |
| 496 | let [x, y, w, h] = batch.scissor.map(|value| value as i32); | |
| 497 | let rect = RECT { | |
| 498 | left: x, | |
| 499 | top: y, | |
| 500 | right: x + w, | |
| 501 | bottom: y + h, | |
| 502 | }; | |
| 503 | context.RSSetScissorRects(Some(&[rect])); | |
| 504 | let (resource, sampler) = match batch.blend { | |
| 505 | Blend::Image(id) => (frame.image::<Image>(id).0.as_ref(), &gpu.linear), | |
| 506 | Blend::Group(index) => (Some(&gpu.groups[index].resource), &gpu.linear), | |
| 507 | Blend::Over | Blend::Erase | Blend::Multiply => { | |
| 508 | (Some(&gpu.atlas.1), &gpu.nearest) | |
| 509 | } | |
| 510 | }; | |
| 511 | context.OMSetBlendState(&gpu.blends[batch.blend.state()], None, u32::MAX); | |
| 512 | context.PSSetSamplers(0, Some(&[Some(sampler.clone())])); | |
| 513 | context.PSSetShaderResources(0, Some(&[resource.cloned()])); | |
| 514 | context.Draw(batch.vertices.len() as u32, batch.vertices.start); | |
| 515 | } | |
| 516 | }; | |
| 517 | for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 518 | paint(picture, [0.0; 4], batches); | |
| 519 | } | |
| 520 | paint(target, clear, frame.batches); | |
| 521 | unsafe { | |
| 522 | context.PSSetShaderResources(0, Some(&[None])); | |
| 523 | context.OMSetRenderTargets(None, None); | |
| 524 | } | |
| 525 | } | |
| 526 | } | |
| 527 | ||
| 528 | type Composition = ( | |
| 529 | IDCompositionDevice, | |
| 530 | IDCompositionTarget, | |
| 531 | IDCompositionVisual, | |
| 532 | ); | |
| 533 | ||
| 534 | /// A flip-model swap chain with premultiplied alpha, which DirectComposition shows over | |
| 535 | /// all of `hwnd`. | |
| 536 | fn compose( | |
| 537 | device: &ID3D11Device, | |
| 538 | factory: &IDXGIFactory, | |
| 539 | hwnd: HWND, | |
| 540 | ) -> Result<(IDXGISwapChain, Composition)> { | |
| 541 | let mut client = RECT::default(); | |
| 542 | unsafe { GetClientRect(hwnd, &mut client)? }; | |
| 543 | let description = DXGI_SWAP_CHAIN_DESC1 { | |
| 544 | Width: client.right.max(1) as u32, | |
| 545 | Height: client.bottom.max(1) as u32, | |
| 546 | Format: DXGI_FORMAT_B8G8R8A8_UNORM, | |
| 547 | SampleDesc: DXGI_SAMPLE_DESC { | |
| 548 | Count: 1, | |
| 549 | Quality: 0, | |
| 550 | }, | |
| 551 | BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, | |
| 552 | BufferCount: 2, | |
| 553 | Scaling: DXGI_SCALING_STRETCH, | |
| 554 | SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, | |
| 555 | AlphaMode: DXGI_ALPHA_MODE_PREMULTIPLIED, | |
| 556 | ..Default::default() | |
| 557 | }; | |
| 558 | // Windows 7 has no dcomp.dll, so it is looked up rather than linked. | |
| 559 | type Create = unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT; | |
| 560 | unsafe { | |
| 561 | let factory: IDXGIFactory2 = factory.cast()?; | |
| 562 | let swap_chain = factory.CreateSwapChainForComposition(device, &description, None)?; | |
| 563 | let library = LoadLibraryW(w!("dcomp.dll"))?; | |
| 564 | let create = GetProcAddress(library, s!("DCompositionCreateDevice")) | |
| 565 | .ok_or_else(Error::from_thread)?; | |
| 566 | let create = std::mem::transmute::<unsafe extern "system" fn() -> isize, Create>(create); | |
| 567 | let mut composition = ptr::null_mut(); | |
| 568 | let dxgi: IDXGIDevice = device.cast()?; | |
| 569 | create(dxgi.as_raw(), &IDCompositionDevice::IID, &mut composition).ok()?; | |
| 570 | let composition = IDCompositionDevice::from_raw(composition); | |
| 571 | let target = composition.CreateTargetForHwnd(hwnd, true)?; | |
| 572 | let visual = composition.CreateVisual()?; | |
| 573 | visual.SetContent(&swap_chain)?; | |
| 574 | target.SetRoot(&visual)?; | |
| 575 | composition.Commit()?; | |
| 576 | Ok((swap_chain.cast()?, (composition, target, visual))) | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | /// An empty atlas `side` texels square. | |
| 581 | fn atlas(device: &ID3D11Device, side: u32) -> Result<(ID3D11Texture2D, ID3D11ShaderResourceView)> { | |
| 582 | let texture = texture(device, [side; 2], D3D11_BIND_SHADER_RESOURCE, None)?; | |
| 583 | let view = resource(device, &texture)?; | |
| 584 | Ok((texture, view)) | |
| 585 | } |
crates/draw/src/render/dual.rs+92-39| ... | ... | @@ -1,10 +1,13 @@ |
| 1 | //! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1 | |
| 2 | //! where it doesn't, as on Windows 7. Whoever makes the renderer picks one. | |
| 3 | use super::{gl, webgpu, *}; | |
| 1 | //! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11 | |
| 2 | //! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the | |
| 3 | //! renderer picks one. | |
| 4 | use super::{d3d11, gl, webgpu, *}; | |
| 5 | use std::ffi::c_void; | |
| 4 | 6 | |
| 5 | 7 | /// What a frame is drawn into, for the backend the renderer draws with. |
| 6 | 8 | pub enum Target { |
| 7 | 9 | Wgpu(wgpu::TextureView), |
| 10 | D3d11(d3d11::Target), | |
| 8 | 11 | Gl(gl::Target), |
| 9 | 12 | } |
| 10 | 13 | |
| ... | ... | @@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target { |
| 14 | 17 | } |
| 15 | 18 | } |
| 16 | 19 | |
| 17 | impl From<gl::Target> for Target { | |
| 18 | fn from(target: gl::Target) -> Self { | |
| 19 | Self::Gl(target) | |
| 20 | impl Target { | |
| 21 | pub fn size(&self) -> [u32; 2] { | |
| 22 | match self { | |
| 23 | Self::Wgpu(view) => [view.texture().width(), view.texture().height()], | |
| 24 | Self::D3d11(target) => target.size(), | |
| 25 | Self::Gl(target) => target.size(), | |
| 26 | } | |
| 20 | 27 | } |
| 21 | 28 | } |
| 22 | 29 | |
| 23 | 30 | pub(super) enum Image { |
| 24 | 31 | Wgpu(webgpu::Image), |
| 32 | D3d11(d3d11::Image), | |
| 25 | 33 | Gl(gl::Image), |
| 26 | 34 | } |
| 27 | 35 | |
| ... | ... | @@ -29,7 +37,16 @@ impl AsRef<webgpu::Image> for Image { |
| 29 | 37 | fn as_ref(&self) -> &webgpu::Image { |
| 30 | 38 | match self { |
| 31 | 39 | Self::Wgpu(image) => image, |
| 32 | Self::Gl(_) => unreachable!("Images belong to the renderer's backend"), | |
| 40 | _ => unreachable!("Images belong to the renderer's backend"), | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | impl AsRef<d3d11::Image> for Image { | |
| 46 | fn as_ref(&self) -> &d3d11::Image { | |
| 47 | match self { | |
| 48 | Self::D3d11(image) => image, | |
| 49 | _ => unreachable!("Images belong to the renderer's backend"), | |
| 33 | 50 | } |
| 34 | 51 | } |
| 35 | 52 | } |
| ... | ... | @@ -38,74 +55,109 @@ impl AsRef<gl::Image> for Image { |
| 38 | 55 | fn as_ref(&self) -> &gl::Image { |
| 39 | 56 | match self { |
| 40 | 57 | Self::Gl(image) => image, |
| 41 | Self::Wgpu(_) => unreachable!("Images belong to the renderer's backend"), | |
| 58 | _ => unreachable!("Images belong to the renderer's backend"), | |
| 42 | 59 | } |
| 43 | 60 | } |
| 44 | 61 | } |
| 45 | 62 | |
| 46 | 63 | pub(super) enum Gpu { |
| 47 | 64 | Wgpu(webgpu::Gpu), |
| 65 | D3d11(d3d11::Gpu), | |
| 48 | 66 | Gl(gl::Gpu), |
| 49 | 67 | } |
| 50 | 68 | |
| 51 | impl Renderer { | |
| 52 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | |
| 53 | Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format))) | |
| 69 | impl From<webgpu::Gpu> for Gpu { | |
| 70 | fn from(gpu: webgpu::Gpu) -> Self { | |
| 71 | Self::Wgpu(gpu) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | impl From<gl::Gpu> for Gpu { | |
| 76 | fn from(gpu: gl::Gpu) -> Self { | |
| 77 | Self::Gl(gpu) | |
| 54 | 78 | } |
| 79 | } | |
| 55 | 80 | |
| 56 | /// Draws with the OpenGL context current on this thread, which must stay current | |
| 57 | /// whenever the renderer or a `Target` is used. | |
| 58 | pub fn opengl() -> Result<Self, String> { | |
| 59 | gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu))) | |
| 81 | impl Renderer { | |
| 82 | /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's | |
| 83 | /// description. | |
| 84 | pub fn direct3d11(window: *mut c_void) -> Result<(Self, String), String> { | |
| 85 | d3d11::Gpu::new(window) | |
| 86 | .map(|(gpu, adapter)| (Self::with_gpu(Gpu::D3d11(gpu)), adapter)) | |
| 87 | .map_err(|error| error.to_string()) | |
| 60 | 88 | } |
| 89 | } | |
| 61 | 90 | |
| 62 | /// Copies `target` to the OpenGL context's window, as `gl`'s renderer does. | |
| 63 | pub fn present_translucent(&self, target: &gl::Target) { | |
| 64 | if let Gpu::Gl(gpu) = &self.gpu { | |
| 65 | gpu.present_translucent(target); | |
| 91 | /// `$gpu` as whichever backend it holds, named `$backend` in `$body`. | |
| 92 | macro_rules! each { | |
| 93 | ($gpu:expr, $backend:ident => $body:expr) => { | |
| 94 | match $gpu { | |
| 95 | Gpu::Wgpu($backend) => $body, | |
| 96 | Gpu::D3d11($backend) => $body, | |
| 97 | Gpu::Gl($backend) => $body, | |
| 66 | 98 | } |
| 67 | } | |
| 99 | }; | |
| 68 | 100 | } |
| 69 | 101 | |
| 70 | 102 | impl Gpu { |
| 71 | pub(super) fn flipped(&self) -> bool { | |
| 103 | pub(super) fn target(&self, size: [u32; 2]) -> Result<Target, String> { | |
| 72 | 104 | match self { |
| 73 | Self::Wgpu(gpu) => gpu.flipped(), | |
| 74 | Self::Gl(gpu) => gpu.flipped(), | |
| 105 | Self::D3d11(gpu) => gpu | |
| 106 | .target(size) | |
| 107 | .map(Target::D3d11) | |
| 108 | .map_err(|error| error.to_string()), | |
| 109 | Self::Gl(gpu) => gpu.target(size).map(Target::Gl), | |
| 110 | Self::Wgpu(_) => unreachable!("wgpu's targets are its surface's"), | |
| 75 | 111 | } |
| 76 | 112 | } |
| 77 | 113 | |
| 78 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 79 | match self { | |
| 80 | Self::Wgpu(gpu) => gpu.max_texture_dimension(), | |
| 81 | Self::Gl(gpu) => gpu.max_texture_dimension(), | |
| 114 | pub(super) fn present(&self, target: &Target, translucent: bool) { | |
| 115 | match (self, target) { | |
| 116 | // Direct3D 11 premultiplies every frame again, which leaves opaque ones be. | |
| 117 | (Self::D3d11(gpu), Target::D3d11(target)) => { | |
| 118 | if let Err(error) = gpu.present(target) { | |
| 119 | eprintln!("Presenting failed: {error}"); | |
| 120 | } | |
| 121 | } | |
| 122 | (Self::Gl(gpu), Target::Gl(target)) => gpu.present(target, translucent), | |
| 123 | _ => unreachable!("Frames are drawn into the renderer's backend's targets"), | |
| 82 | 124 | } |
| 83 | 125 | } |
| 84 | 126 | |
| 85 | pub(super) fn atlas_side(&self) -> u32 { | |
| 86 | match self { | |
| 87 | Self::Wgpu(gpu) => gpu.atlas_side(), | |
| 88 | Self::Gl(gpu) => gpu.atlas_side(), | |
| 127 | pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> { | |
| 128 | match (self, target) { | |
| 129 | (Self::D3d11(gpu), Target::D3d11(target)) => { | |
| 130 | gpu.read_pixels(target).map_err(|error| error.to_string()) | |
| 131 | } | |
| 132 | (Self::Gl(gpu), Target::Gl(target)) => gpu.read_pixels(target), | |
| 133 | _ => unreachable!("Snapshots come from the renderer's backend's targets"), | |
| 89 | 134 | } |
| 90 | 135 | } |
| 91 | 136 | |
| 137 | pub(super) fn flipped(&self) -> bool { | |
| 138 | each!(self, gpu => gpu.flipped()) | |
| 139 | } | |
| 140 | ||
| 141 | pub(super) fn max_texture_dimension(&self) -> u32 { | |
| 142 | each!(self, gpu => gpu.max_texture_dimension()) | |
| 143 | } | |
| 144 | ||
| 145 | pub(super) fn atlas_side(&self) -> u32 { | |
| 146 | each!(self, gpu => gpu.atlas_side()) | |
| 147 | } | |
| 148 | ||
| 92 | 149 | pub(super) fn new_atlas(&mut self, side: u32) { |
| 93 | match self { | |
| 94 | Self::Wgpu(gpu) => gpu.new_atlas(side), | |
| 95 | Self::Gl(gpu) => gpu.new_atlas(side), | |
| 96 | } | |
| 150 | each!(self, gpu => gpu.new_atlas(side)) | |
| 97 | 151 | } |
| 98 | 152 | |
| 99 | 153 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| 100 | match self { | |
| 101 | Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba), | |
| 102 | Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba), | |
| 103 | } | |
| 154 | each!(self, gpu => gpu.write_atlas(origin, size, rgba)) | |
| 104 | 155 | } |
| 105 | 156 | |
| 106 | 157 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 107 | 158 | match self { |
| 108 | 159 | Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), |
| 160 | Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)), | |
| 109 | 161 | Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), |
| 110 | 162 | } |
| 111 | 163 | } |
| ... | ... | @@ -119,6 +171,7 @@ impl Gpu { |
| 119 | 171 | ) { |
| 120 | 172 | match (self, target) { |
| 121 | 173 | (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), |
| 174 | (Self::D3d11(gpu), Target::D3d11(target)) => gpu.submit(frame, target, size, clear), | |
| 122 | 175 | (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), |
| 123 | 176 | _ => unreachable!("Frames are drawn into the renderer's backend's targets"), |
| 124 | 177 | } |
crates/draw/src/render/dxbc/compile.ps1 created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | # Compiles draw.hlsl and translucent.hlsl to the shader model 4.0 bytecode d3d11.rs embeds, | |
| 2 | # with the d3dcompiler_47.dll Windows 8.1 and later ship: `powershell -File compile.ps1`. | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | Add-Type -TypeDefinition @' | |
| 5 | using System; | |
| 6 | using System.IO; | |
| 7 | using System.Runtime.InteropServices; | |
| 8 | using System.Text; | |
| 9 | ||
| 10 | [ComImport, Guid("8BA5FB08-5195-40e2-AC58-0D989C3A0102"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] | |
| 11 | public interface ID3DBlob { | |
| 12 | [PreserveSig] IntPtr GetBufferPointer(); | |
| 13 | [PreserveSig] UIntPtr GetBufferSize(); | |
| 14 | } | |
| 15 | ||
| 16 | public static class Fxc { | |
| 17 | [DllImport("d3dcompiler_47.dll", CharSet = CharSet.Ansi)] | |
| 18 | static extern int D3DCompile(byte[] source, UIntPtr size, string name, IntPtr defines, | |
| 19 | IntPtr include, string entry, string target, uint flags, uint effectFlags, | |
| 20 | out ID3DBlob code, out ID3DBlob errors); | |
| 21 | ||
| 22 | static byte[] Bytes(ID3DBlob blob) { | |
| 23 | var bytes = new byte[(int)blob.GetBufferSize()]; | |
| 24 | Marshal.Copy(blob.GetBufferPointer(), bytes, 0, bytes.Length); | |
| 25 | return bytes; | |
| 26 | } | |
| 27 | ||
| 28 | public static byte[] Compile(string path, string entry, string target) { | |
| 29 | var source = File.ReadAllBytes(path); | |
| 30 | ID3DBlob code, errors; | |
| 31 | // D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3 | |
| 32 | int result = D3DCompile(source, (UIntPtr)source.Length, Path.GetFileName(path), | |
| 33 | IntPtr.Zero, IntPtr.Zero, entry, target, (1u << 11) | (1u << 15), 0, | |
| 34 | out code, out errors); | |
| 35 | if (result < 0) { | |
| 36 | throw new Exception(errors == null ? "D3DCompile: 0x" + result.ToString("x8") | |
| 37 | : Encoding.ASCII.GetString(Bytes(errors))); | |
| 38 | } | |
| 39 | return Bytes(code); | |
| 40 | } | |
| 41 | } | |
| 42 | '@ | |
| 43 | foreach ($shader in @( | |
| 44 | @('..\..\draw.hlsl', 'draw'), | |
| 45 | @('..\translucent.hlsl', 'translucent') | |
| 46 | )) { | |
| 47 | $source = Join-Path $PSScriptRoot $shader[0] | |
| 48 | foreach ($stage in @(@('vertex', 'vs_4_0'), @('fragment', 'ps_4_0'))) { | |
| 49 | $out = Join-Path $PSScriptRoot ($shader[1] + '.' + $stage[0] + '.dxbc') | |
| 50 | [IO.File]::WriteAllBytes($out, [Fxc]::Compile($source, $stage[0], $stage[1])) | |
| 51 | } | |
| 52 | } |
crates/draw/src/render/dxbc/draw.fragment.dxbc created| Binary files /dev/null and b/crates/draw/src/render/dxbc/draw.fragment.dxbc differ |
crates/draw/src/render/dxbc/draw.vertex.dxbc created| Binary files /dev/null and b/crates/draw/src/render/dxbc/draw.vertex.dxbc differ |
crates/draw/src/render/dxbc/translucent.fragment.dxbc created| Binary files /dev/null and b/crates/draw/src/render/dxbc/translucent.fragment.dxbc differ |
crates/draw/src/render/dxbc/translucent.vertex.dxbc created| Binary files /dev/null and b/crates/draw/src/render/dxbc/translucent.vertex.dxbc differ |
crates/draw/src/render/gl.rs+84-136| ... | ... | @@ -7,7 +7,6 @@ |
| 7 | 7 | use super::*; |
| 8 | 8 | use std::{ |
| 9 | 9 | ffi::{CStr, CString, c_char, c_void}, |
| 10 | mem::offset_of, | |
| 11 | 10 | ptr, |
| 12 | 11 | }; |
| 13 | 12 | |
| ... | ... | @@ -250,9 +249,6 @@ functions! { |
| 250 | 249 | ); |
| 251 | 250 | } |
| 252 | 251 | |
| 253 | /// Blending a premultiplied picture over what lies beneath. | |
| 254 | const PREMULTIPLIED: [GLenum; 4] = [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA]; | |
| 255 | ||
| 256 | 252 | /// An sRGB RGBA texture, deleted with its value. |
| 257 | 253 | pub(super) struct Image { |
| 258 | 254 | name: GLuint, |
| ... | ... | @@ -305,9 +301,9 @@ pub struct Target { |
| 305 | 301 | } |
| 306 | 302 | |
| 307 | 303 | impl Target { |
| 308 | /// Needs the renderer's context current, as every method here does. | |
| 309 | pub fn new(size: [u32; 2]) -> Result<Self, String> { | |
| 310 | let texture = Image::new(size, NEAREST, None); | |
| 304 | /// Sampled with `filter`. | |
| 305 | fn new(size: [u32; 2], filter: GLint) -> Result<Self, String> { | |
| 306 | let texture = Image::new(size, filter, None); | |
| 311 | 307 | let mut framebuffer = 0; |
| 312 | 308 | unsafe { |
| 313 | 309 | glGenFramebuffersEXT(1, &mut framebuffer); |
| ... | ... | @@ -329,53 +325,6 @@ impl Target { |
| 329 | 325 | pub fn size(&self) -> [u32; 2] { |
| 330 | 326 | self.texture.size |
| 331 | 327 | } |
| 332 | ||
| 333 | /// Copies the frame to the context's window, which is the same size. | |
| 334 | pub fn present(&self) { | |
| 335 | let [width, height] = self.size().map(|side| side as GLint); | |
| 336 | unsafe { | |
| 337 | glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer); | |
| 338 | glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); | |
| 339 | glBlitFramebufferEXT( | |
| 340 | 0, | |
| 341 | 0, | |
| 342 | width, | |
| 343 | height, | |
| 344 | 0, | |
| 345 | 0, | |
| 346 | width, | |
| 347 | height, | |
| 348 | COLOR_BUFFER_BIT, | |
| 349 | NEAREST as GLenum, | |
| 350 | ); | |
| 351 | glBindFramebufferEXT(FRAMEBUFFER, 0); | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | /// sRGB-encoded RGBA rows, top first. | |
| 356 | pub fn read_pixels(&self) -> Vec<u8> { | |
| 357 | let [width, height] = self.size(); | |
| 358 | let mut pixels = vec![0; (width * height * 4) as usize]; | |
| 359 | unsafe { | |
| 360 | glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer); | |
| 361 | glReadPixels( | |
| 362 | 0, | |
| 363 | 0, | |
| 364 | width as GLsizei, | |
| 365 | height as GLsizei, | |
| 366 | RGBA, | |
| 367 | UNSIGNED_BYTE, | |
| 368 | pixels.as_mut_ptr().cast(), | |
| 369 | ); | |
| 370 | glBindFramebufferEXT(FRAMEBUFFER, 0); | |
| 371 | } | |
| 372 | pixels | |
| 373 | .chunks(width as usize * 4) | |
| 374 | .rev() | |
| 375 | .flatten() | |
| 376 | .copied() | |
| 377 | .collect() | |
| 378 | } | |
| 379 | 328 | } |
| 380 | 329 | |
| 381 | 330 | impl Drop for Target { |
| ... | ... | @@ -396,38 +345,10 @@ pub(super) struct Gpu { |
| 396 | 345 | groups: Vec<Target>, |
| 397 | 346 | } |
| 398 | 347 | |
| 399 | /// Each attribute's name, component count and offset, bound to its index in the shader. | |
| 400 | const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ | |
| 401 | (c"position", 2, offset_of!(Vertex, position)), | |
| 402 | (c"uv", 2, offset_of!(Vertex, uv)), | |
| 403 | (c"color", 4, offset_of!(Vertex, color)), | |
| 404 | (c"local", 2, offset_of!(Vertex, local)), | |
| 405 | (c"shape", 4, offset_of!(Vertex, shape)), | |
| 406 | (c"stroke", 1, offset_of!(Vertex, stroke)), | |
| 407 | (c"clip_local", 2, offset_of!(Vertex, clip_local)), | |
| 408 | (c"clip", 3, offset_of!(Vertex, clip)), | |
| 409 | (c"blur", 1, offset_of!(Vertex, blur)), | |
| 410 | ]; | |
| 411 | ||
| 412 | #[cfg(not(feature = "wgpu"))] | |
| 413 | impl Renderer { | |
| 414 | /// Draws with the OpenGL context current on this thread, which must stay current | |
| 415 | /// whenever the renderer or a `Target` is used. | |
| 416 | pub fn opengl() -> Result<Self, String> { | |
| 417 | Gpu::new().map(Self::with_gpu) | |
| 418 | } | |
| 419 | ||
| 420 | /// Copies `target` to the context's window, the same size, premultiplying each pixel | |
| 421 | /// again in sRGB, as a window server compositing a transparent surface needs. | |
| 422 | pub fn present_translucent(&self, target: &Target) { | |
| 423 | self.gpu.present_translucent(target); | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | 348 | impl Gpu { |
| 428 | 349 | pub(super) fn new() -> Result<Self, String> { |
| 429 | 350 | load()?; |
| 430 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); | |
| 351 | let names = ATTRIBUTES.map(|(name, _, _)| name); | |
| 431 | 352 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; |
| 432 | 353 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; |
| 433 | 354 | let [mut buffer, mut triangle] = [0; 2]; |
| ... | ... | @@ -445,7 +366,7 @@ impl Gpu { |
| 445 | 366 | ); |
| 446 | 367 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); |
| 447 | 368 | } |
| 448 | Ok(Gpu { | |
| 369 | Ok(Self { | |
| 449 | 370 | program: draw, |
| 450 | 371 | buffer, |
| 451 | 372 | translucent, |
| ... | ... | @@ -456,7 +377,36 @@ impl Gpu { |
| 456 | 377 | }) |
| 457 | 378 | } |
| 458 | 379 | |
| 459 | pub(super) fn present_translucent(&self, target: &Target) { | |
| 380 | /// Needs the renderer's context current, as every method here does. | |
| 381 | pub(super) fn target(&self, size: [u32; 2]) -> Result<Target, String> { | |
| 382 | Target::new(size, NEAREST) | |
| 383 | } | |
| 384 | ||
| 385 | /// Copies `target` to the context's window, the same size; if `translucent`, | |
| 386 | /// premultiplying each pixel again in sRGB, as a window server compositing a transparent | |
| 387 | /// surface needs. | |
| 388 | pub(super) fn present(&self, target: &Target, translucent: bool) { | |
| 389 | if !translucent { | |
| 390 | let [width, height] = target.size().map(|side| side as GLint); | |
| 391 | unsafe { | |
| 392 | glBindFramebufferEXT(READ_FRAMEBUFFER, target.framebuffer); | |
| 393 | glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); | |
| 394 | glBlitFramebufferEXT( | |
| 395 | 0, | |
| 396 | 0, | |
| 397 | width, | |
| 398 | height, | |
| 399 | 0, | |
| 400 | 0, | |
| 401 | width, | |
| 402 | height, | |
| 403 | COLOR_BUFFER_BIT, | |
| 404 | NEAREST as GLenum, | |
| 405 | ); | |
| 406 | glBindFramebufferEXT(FRAMEBUFFER, 0); | |
| 407 | } | |
| 408 | return; | |
| 409 | } | |
| 460 | 410 | let [width, height] = target.size().map(|side| side as f32); |
| 461 | 411 | let program = self.translucent; |
| 462 | 412 | unsafe { |
| ... | ... | @@ -518,6 +468,31 @@ impl Gpu { |
| 518 | 468 | } |
| 519 | 469 | } |
| 520 | 470 | |
| 471 | /// sRGB-encoded RGBA rows, top first. | |
| 472 | pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> { | |
| 473 | let [width, height] = target.size(); | |
| 474 | let mut pixels = vec![0; (width * height * 4) as usize]; | |
| 475 | unsafe { | |
| 476 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); | |
| 477 | glReadPixels( | |
| 478 | 0, | |
| 479 | 0, | |
| 480 | width as GLsizei, | |
| 481 | height as GLsizei, | |
| 482 | RGBA, | |
| 483 | UNSIGNED_BYTE, | |
| 484 | pixels.as_mut_ptr().cast(), | |
| 485 | ); | |
| 486 | glBindFramebufferEXT(FRAMEBUFFER, 0); | |
| 487 | } | |
| 488 | Ok(pixels | |
| 489 | .chunks(width as usize * 4) | |
| 490 | .rev() | |
| 491 | .flatten() | |
| 492 | .copied() | |
| 493 | .collect()) | |
| 494 | } | |
| 495 | ||
| 521 | 496 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 522 | 497 | Image::new(image.size, LINEAR, Some(image.pixels())) |
| 523 | 498 | } |
| ... | ... | @@ -539,14 +514,9 @@ impl Gpu { |
| 539 | 514 | self.groups.clear(); |
| 540 | 515 | } |
| 541 | 516 | while self.groups.len() < frame.groups.len() { |
| 542 | match Target::new(size) { | |
| 543 | Ok(picture) => unsafe { | |
| 544 | // Filtered, so a picture leaning back stays smooth. | |
| 545 | glBindTexture(TEXTURE_2D, picture.texture.name); | |
| 546 | glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); | |
| 547 | glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); | |
| 548 | self.groups.push(picture); | |
| 549 | }, | |
| 517 | // Filtered, so a picture leaning back stays smooth. | |
| 518 | match Target::new(size, LINEAR) { | |
| 519 | Ok(picture) => self.groups.push(picture), | |
| 550 | 520 | Err(error) => { |
| 551 | 521 | eprintln!("{error}"); |
| 552 | 522 | return; |
| ... | ... | @@ -555,36 +525,30 @@ impl Gpu { |
| 555 | 525 | } |
| 556 | 526 | let [width, height] = size.map(|side| side as GLsizei); |
| 557 | 527 | let gpu = &*self; |
| 558 | let begin = |target: &Target, clear: [f32; 4]| unsafe { | |
| 528 | let paint = |target: &Target, clear: [f32; 4], batches: &[Batch]| unsafe { | |
| 559 | 529 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); |
| 560 | 530 | glDisable(SCISSOR_TEST); |
| 561 | 531 | glClearColor(clear[0], clear[1], clear[2], clear[3]); |
| 562 | 532 | glClear(COLOR_BUFFER_BIT); |
| 563 | 533 | glEnable(SCISSOR_TEST); |
| 564 | }; | |
| 565 | let draw = |batches: &[Batch]| unsafe { | |
| 566 | 534 | for batch in batches { |
| 567 | 535 | let [x, y, w, h] = batch.scissor.map(|value| value as GLint); |
| 568 | 536 | glScissor(x, height - y - h, w, h); |
| 569 | let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { | |
| 570 | Blend::Over => ( | |
| 571 | gpu.atlas.name, | |
| 572 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | |
| 573 | ), | |
| 574 | Blend::Image(id) => { | |
| 575 | let image: &Image = frame.images[&id].texture.as_ref(); | |
| 576 | (image.name, PREMULTIPLIED) | |
| 577 | } | |
| 578 | Blend::Erase => ( | |
| 579 | gpu.atlas.name, | |
| 580 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], | |
| 581 | ), | |
| 582 | Blend::Multiply => { | |
| 583 | (gpu.atlas.name, [DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE]) | |
| 584 | } | |
| 585 | }; | |
| 537 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = Blend::STATES[batch.blend.state()] | |
| 538 | .map(|factor| match factor { | |
| 539 | Factor::Zero => ZERO, | |
| 540 | Factor::One => ONE, | |
| 541 | Factor::SourceAlpha => SRC_ALPHA, | |
| 542 | Factor::OneMinusSourceAlpha => ONE_MINUS_SRC_ALPHA, | |
| 543 | Factor::Destination => DST_COLOR, | |
| 544 | }); | |
| 586 | 545 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); |
| 587 | glBindTexture(TEXTURE_2D, texture); | |
| 546 | let texture = match batch.blend { | |
| 547 | Blend::Image(id) => frame.image::<Image>(id), | |
| 548 | Blend::Group(index) => &gpu.groups[index].texture, | |
| 549 | Blend::Over | Blend::Erase | Blend::Multiply => &gpu.atlas, | |
| 550 | }; | |
| 551 | glBindTexture(TEXTURE_2D, texture.name); | |
| 588 | 552 | glDrawArrays( |
| 589 | 553 | TRIANGLES, |
| 590 | 554 | batch.vertices.start as GLint, |
| ... | ... | @@ -605,38 +569,22 @@ impl Gpu { |
| 605 | 569 | frame.vertices.as_ptr().cast(), |
| 606 | 570 | STREAM_DRAW, |
| 607 | 571 | ); |
| 608 | for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { | |
| 572 | for (index, (_, floats, offset)) in ATTRIBUTES.into_iter().enumerate() { | |
| 609 | 573 | glEnableVertexAttribArray(index as GLuint); |
| 610 | 574 | glVertexAttribPointer( |
| 611 | 575 | index as GLuint, |
| 612 | *components, | |
| 576 | floats as GLint, | |
| 613 | 577 | FLOAT, |
| 614 | 578 | 0, |
| 615 | 579 | size_of::<Vertex>() as GLsizei, |
| 616 | ptr::without_provenance(*offset), | |
| 580 | ptr::without_provenance(offset), | |
| 617 | 581 | ); |
| 618 | 582 | } |
| 619 | 583 | glEnable(BLEND); |
| 620 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 621 | begin(picture, [0.0; 4]); | |
| 622 | draw(&frame.batches[group.batches.clone()]); | |
| 623 | } | |
| 624 | begin(target, clear); | |
| 625 | let mut next = 0; | |
| 626 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 627 | draw(&frame.batches[next..group.batches.start]); | |
| 628 | glScissor(0, 0, width, height); | |
| 629 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; | |
| 630 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); | |
| 631 | glBindTexture(TEXTURE_2D, picture.texture.name); | |
| 632 | glDrawArrays( | |
| 633 | TRIANGLES, | |
| 634 | group.composite.start as GLint, | |
| 635 | group.composite.len() as GLsizei, | |
| 636 | ); | |
| 637 | next = group.batches.end; | |
| 584 | for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { | |
| 585 | paint(picture, [0.0; 4], batches); | |
| 638 | 586 | } |
| 639 | draw(&frame.batches[next..]); | |
| 587 | paint(target, clear, frame.batches); | |
| 640 | 588 | glDisable(SCISSOR_TEST); |
| 641 | 589 | glDisable(BLEND); |
| 642 | 590 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
crates/draw/src/render/icon.rs+20-62| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | use super::{from_hsl, to_hsl}; | |
| 1 | 2 | use swash::{ |
| 2 | 3 | scale::image::{Content, Image}, |
| 3 | 4 | zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform}, |
| ... | ... | @@ -140,11 +141,7 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa |
| 140 | 141 | let [x, y] = [index % side as usize, index / side as usize] |
| 141 | 142 | .map(|device| device as f32 + 0.5) |
| 142 | 143 | .map(|device| device * 16.0 / size); |
| 143 | let color = color([x, y]); | |
| 144 | for channel in 0..3 { | |
| 145 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); | |
| 146 | } | |
| 147 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | |
| 144 | over(pixel, color([x, y]), alpha); | |
| 148 | 145 | } |
| 149 | 146 | } |
| 150 | 147 | } |
| ... | ... | @@ -224,13 +221,16 @@ fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) { |
| 224 | 221 | for (column, row, color) in shown.enumerate_pixels() { |
| 225 | 222 | let pixel = &mut pixels[((top + row) * side + left + column) as usize]; |
| 226 | 223 | 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); | |
| 224 | over(pixel, super::srgb(r, g, b), f32::from(a) / 255.0); | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /// Paints `color` at `alpha` over premultiplied `pixel`. | |
| 229 | fn over(pixel: &mut [f32; 4], color: [f32; 4], alpha: f32) { | |
| 230 | for channel in 0..3 { | |
| 231 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); | |
| 233 | 232 | } |
| 233 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | 236 | fn hex(value: &str) -> [f32; 4] { |
| ... | ... | @@ -364,11 +364,11 @@ impl Shift { |
| 364 | 364 | let mean: [f32; 3] = std::array::from_fn(|channel| { |
| 365 | 365 | colors |
| 366 | 366 | .iter() |
| 367 | .map(|color| encode(color[channel])) | |
| 367 | .map(|color| crate::encode(color[channel])) | |
| 368 | 368 | .sum::<f32>() |
| 369 | 369 | / count |
| 370 | 370 | }); |
| 371 | let [from, to] = [mean, target.map(encode)].map(hsl); | |
| 371 | let [from, to] = [mean, target.map(crate::encode)].map(to_hsl); | |
| 372 | 372 | (count > 0.0).then(|| { |
| 373 | 373 | Self([ |
| 374 | 374 | to[0] - from[0], |
| ... | ... | @@ -379,61 +379,19 @@ impl Shift { |
| 379 | 379 | } |
| 380 | 380 | |
| 381 | 381 | fn apply(&self, color: [f32; 4]) -> [f32; 4] { |
| 382 | let [hue, saturation, lightness] = hsl([color[0], color[1], color[2]].map(encode)); | |
| 382 | let [hue, saturation, lightness] = | |
| 383 | to_hsl([color[0], color[1], color[2]].map(crate::encode)); | |
| 383 | 384 | let [turn, scale, lift] = self.0; |
| 384 | let [r, g, b] = rgb([ | |
| 385 | let [r, g, b] = from_hsl([ | |
| 385 | 386 | (hue + turn).rem_euclid(360.0), |
| 386 | 387 | (saturation * scale).clamp(0.0, 1.0), |
| 387 | 388 | (lightness + lift).clamp(0.0, 1.0), |
| 388 | 389 | ]) |
| 389 | .map(decode); | |
| 390 | .map(crate::linear); | |
| 390 | 391 | [r, g, b, color[3]] |
| 391 | 392 | } |
| 392 | 393 | } |
| 393 | 394 | |
| 394 | fn encode(linear: f32) -> f32 { | |
| 395 | if linear <= 0.003_130_8 { | |
| 396 | linear * 12.92 | |
| 397 | } else { | |
| 398 | 1.055 * linear.powf(1.0 / 2.4) - 0.055 | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | fn decode(encoded: f32) -> f32 { | |
| 403 | if encoded <= 0.040_45 { | |
| 404 | encoded / 12.92 | |
| 405 | } else { | |
| 406 | ((encoded + 0.055) / 1.055).powf(2.4) | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | /// Hue in degrees, saturation and lightness of an encoded sRGB colour. | |
| 411 | fn hsl([r, g, b]: [f32; 3]) -> [f32; 3] { | |
| 412 | let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; | |
| 413 | let lightness = (max + min) / 2.0; | |
| 414 | let range = max - min; | |
| 415 | if range == 0.0 { | |
| 416 | return [0.0, 0.0, lightness]; | |
| 417 | } | |
| 418 | let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs()); | |
| 419 | let sector = if max == r { | |
| 420 | (g - b) / range | |
| 421 | } else if max == g { | |
| 422 | (b - r) / range + 2.0 | |
| 423 | } else { | |
| 424 | (r - g) / range + 4.0 | |
| 425 | }; | |
| 426 | [(sector * 60.0).rem_euclid(360.0), saturation, lightness] | |
| 427 | } | |
| 428 | ||
| 429 | fn rgb([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] { | |
| 430 | let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; | |
| 431 | [0.0, 8.0, 4.0].map(|offset: f32| { | |
| 432 | let k = (offset + hue / 30.0).rem_euclid(12.0); | |
| 433 | lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) | |
| 434 | }) | |
| 435 | } | |
| 436 | ||
| 437 | 395 | /// The tight box around a path's geometry, curves included, as SVG's bounding box is. |
| 438 | 396 | fn bounds(data: &str) -> [[f32; 2]; 2] { |
| 439 | 397 | struct Sampled { |
| ... | ... | @@ -567,11 +525,11 @@ mod tests { |
| 567 | 525 | let image = rasterize(&[ART], 16, [0.0; 3], palette); |
| 568 | 526 | let at = (y * 16 + 8) * 4; |
| 569 | 527 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); |
| 570 | hsl([r, g, b]) | |
| 528 | to_hsl([r, g, b]) | |
| 571 | 529 | }; |
| 572 | 530 | let plain = Palette::default(); |
| 573 | 531 | let green = Palette { |
| 574 | accent: Some([0.0, 1.0, 0.0].map(decode)), | |
| 532 | accent: Some([0.0, 1.0, 0.0].map(crate::linear)), | |
| 575 | 533 | ..plain |
| 576 | 534 | }; |
| 577 | 535 | let [light, dark] = [2, 6].map(|y| pixel(&green, y)); |
| ... | ... | @@ -583,7 +541,7 @@ mod tests { |
| 583 | 541 | assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); |
| 584 | 542 | assert_eq!(pixel(&green, 10), pixel(&plain, 10)); |
| 585 | 543 | assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]); |
| 586 | assert!((pixel(&plain, 2)[0] - hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); | |
| 544 | assert!((pixel(&plain, 2)[0] - to_hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); | |
| 587 | 545 | } |
| 588 | 546 | |
| 589 | 547 | #[test] |
crates/draw/src/render/pdf.rs+32-37| ... | ... | @@ -37,7 +37,6 @@ pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result<Vec<u8>, RenderError> { |
| 37 | 37 | images: HashMap::new(), |
| 38 | 38 | states: HashMap::new(), |
| 39 | 39 | scale: ScaleContext::new(), |
| 40 | rows: None, | |
| 41 | 40 | }; |
| 42 | 41 | let catalog = document.reserve(); |
| 43 | 42 | let tree = document.reserve(); |
| ... | ... | @@ -101,9 +100,6 @@ struct Document { |
| 101 | 100 | /// Graphics states by fill and stroke opacity bits and whether they multiply. |
| 102 | 101 | states: HashMap<(u32, bool), Ref>, |
| 103 | 102 | scale: ScaleContext, |
| 104 | /// The top and bottom of the layer drawn, in its units, where it clips; lines of text | |
| 105 | /// outside it are left out, so that no sheet's text layer holds text it does not show. | |
| 106 | rows: Option<[f32; 2]>, | |
| 107 | 103 | } |
| 108 | 104 | |
| 109 | 105 | #[derive(Hash, PartialEq, Eq)] |
| ... | ... | @@ -258,7 +254,9 @@ impl Document { |
| 258 | 254 | .clip_nonzero() |
| 259 | 255 | .end_path(); |
| 260 | 256 | } |
| 261 | self.rows = layer | |
| 257 | // Lines of text outside a clipping layer are left out, so that no sheet's text | |
| 258 | // layer holds text it does not show. | |
| 259 | let rows = layer | |
| 262 | 260 | .clip |
| 263 | 261 | .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); |
| 264 | 262 | content.transform([ |
| ... | ... | @@ -270,7 +268,7 @@ impl Document { |
| 270 | 268 | layer.origin[1], |
| 271 | 269 | ]); |
| 272 | 270 | for primitive in layer.primitives { |
| 273 | self.primitive(&mut content, &mut used, primitive)?; | |
| 271 | self.primitive(&mut content, &mut used, primitive, rows)?; | |
| 274 | 272 | } |
| 275 | 273 | content.restore_state(); |
| 276 | 274 | } |
| ... | ... | @@ -290,28 +288,30 @@ impl Document { |
| 290 | 288 | content.set_fill_rgb(red, green, blue); |
| 291 | 289 | } |
| 292 | 290 | if color[3] < 1.0 { |
| 293 | let state = self.state(color[3], false); | |
| 294 | Used::add(&mut used.states, state); | |
| 295 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | |
| 291 | self.opacity(content, used, color[3], false); | |
| 296 | 292 | } |
| 297 | 293 | } |
| 298 | 294 | |
| 299 | fn state(&mut self, opacity: f32, multiply: bool) -> Ref { | |
| 300 | let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply); | |
| 301 | if let Some(state) = self.states.get(&key) { | |
| 302 | return *state; | |
| 303 | } | |
| 304 | let state = self.reserve(); | |
| 305 | let mut writer = self.pdf.ext_graphics(state); | |
| 306 | writer | |
| 307 | .non_stroking_alpha(opacity.clamp(0.0, 1.0)) | |
| 308 | .stroking_alpha(opacity.clamp(0.0, 1.0)); | |
| 309 | if multiply { | |
| 310 | writer.blend_mode(BlendMode::Multiply); | |
| 311 | } | |
| 312 | writer.finish(); | |
| 313 | self.states.insert(key, state); | |
| 314 | state | |
| 295 | /// Paints at `opacity`, multiplying what lies beneath if `multiply`. | |
| 296 | fn opacity(&mut self, content: &mut Content, used: &mut Used, opacity: f32, multiply: bool) { | |
| 297 | let opacity = opacity.clamp(0.0, 1.0); | |
| 298 | let key = (opacity.to_bits(), multiply); | |
| 299 | let state = match self.states.get(&key) { | |
| 300 | Some(state) => *state, | |
| 301 | None => { | |
| 302 | let state = self.reserve(); | |
| 303 | let mut writer = self.pdf.ext_graphics(state); | |
| 304 | writer.non_stroking_alpha(opacity).stroking_alpha(opacity); | |
| 305 | if multiply { | |
| 306 | writer.blend_mode(BlendMode::Multiply); | |
| 307 | } | |
| 308 | writer.finish(); | |
| 309 | self.states.insert(key, state); | |
| 310 | state | |
| 311 | } | |
| 312 | }; | |
| 313 | Used::add(&mut used.states, state); | |
| 314 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | |
| 315 | 315 | } |
| 316 | 316 | |
| 317 | 317 | fn primitive( |
| ... | ... | @@ -319,6 +319,7 @@ impl Document { |
| 319 | 319 | content: &mut Content, |
| 320 | 320 | used: &mut Used, |
| 321 | 321 | primitive: &Primitive<'_>, |
| 322 | rows: Option<[f32; 2]>, | |
| 322 | 323 | ) -> Result<(), RenderError> { |
| 323 | 324 | match primitive { |
| 324 | 325 | Primitive::Text { |
| ... | ... | @@ -334,7 +335,7 @@ impl Document { |
| 334 | 335 | .clip_nonzero() |
| 335 | 336 | .end_path(); |
| 336 | 337 | } |
| 337 | text.runs(&mut |run| self.run(content, used, run, *origin, *ink))?; | |
| 338 | text.runs(&mut |run| self.run(content, used, run, *origin, *ink, rows))?; | |
| 338 | 339 | content.restore_state(); |
| 339 | 340 | } |
| 340 | 341 | Primitive::Icon { |
| ... | ... | @@ -364,9 +365,7 @@ impl Document { |
| 364 | 365 | }; |
| 365 | 366 | content.save_state(); |
| 366 | 367 | if tint[3] < 1.0 { |
| 367 | let state = self.state(tint[3], false); | |
| 368 | Used::add(&mut used.states, state); | |
| 369 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | |
| 368 | self.opacity(content, used, tint[3], false); | |
| 370 | 369 | } |
| 371 | 370 | self.place( |
| 372 | 371 | content, |
| ... | ... | @@ -603,9 +602,7 @@ impl Document { |
| 603 | 602 | color, |
| 604 | 603 | } => { |
| 605 | 604 | content.save_state(); |
| 606 | let state = self.state(color[3], true); | |
| 607 | Used::add(&mut used.states, state); | |
| 608 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | |
| 605 | self.opacity(content, used, color[3], true); | |
| 609 | 606 | let [red, green, blue] = encoded(*color); |
| 610 | 607 | content |
| 611 | 608 | .set_stroke_rgb(red, green, blue) |
| ... | ... | @@ -649,7 +646,7 @@ impl Document { |
| 649 | 646 | return [pixel[0], pixel[1], pixel[2], alpha]; |
| 650 | 647 | } |
| 651 | 648 | let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| { |
| 652 | let linear = super::linear(f32::from(byte) / 255.0); | |
| 649 | let linear = crate::linear(f32::from(byte) / 255.0); | |
| 653 | 650 | srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) |
| 654 | 651 | }); |
| 655 | 652 | [red, green, blue, alpha] |
| ... | ... | @@ -756,15 +753,13 @@ impl Document { |
| 756 | 753 | run: GlyphRun<'_>, |
| 757 | 754 | origin: [f32; 2], |
| 758 | 755 | ink: [f32; 4], |
| 756 | rows: Option<[f32; 2]>, | |
| 759 | 757 | ) -> Result<(), RenderError> { |
| 760 | 758 | let [top, bottom] = [ |
| 761 | 759 | origin[1] + run.line[0], |
| 762 | 760 | origin[1] + run.line[0] + run.line[1], |
| 763 | 761 | ]; |
| 764 | if self | |
| 765 | .rows | |
| 766 | .is_some_and(|[first, last]| bottom <= first || top >= last) | |
| 767 | { | |
| 762 | if rows.is_some_and(|[first, last]| bottom <= first || top >= last) { | |
| 768 | 763 | return Ok(()); |
| 769 | 764 | } |
| 770 | 765 | let font = self.font(run.font, run.index)?; |
crates/draw/src/render/translucent.hlsl created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | // Shader model 4.0 twin of translucent.wgsl: the frame, blended in linear light, premultiplied | |
| 2 | // again in sRGB for a window server that composites that way. | |
| 3 | Texture2D frame : register(t0); | |
| 4 | ||
| 5 | float4 vertex(uint index : SV_VertexID) : SV_Position { | |
| 6 | float2 corner = float2((index << 1) & 2, index & 2); | |
| 7 | return float4(corner * 2.0 - 1.0, 0.0, 1.0); | |
| 8 | } | |
| 9 | ||
| 10 | float3 encode(float3 linear_light) { | |
| 11 | return linear_light <= 0.0031308 ? linear_light * 12.92 | |
| 12 | : 1.055 * pow(linear_light, 1.0 / 2.4) - 0.055; | |
| 13 | } | |
| 14 | ||
| 15 | float4 fragment(float4 at : SV_Position) : SV_Target { | |
| 16 | float4 pixel = frame.Load(int3(at.xy, 0)); | |
| 17 | if (pixel.a == 0.0) { | |
| 18 | return 0.0; | |
| 19 | } | |
| 20 | return float4(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a); | |
| 21 | } |
crates/draw/src/render/webgpu.rs+149-197| ... | ... | @@ -17,30 +17,29 @@ impl AsRef<Image> for Image { |
| 17 | 17 | pub(super) struct Gpu { |
| 18 | 18 | device: wgpu::Device, |
| 19 | 19 | queue: wgpu::Queue, |
| 20 | pipeline: wgpu::RenderPipeline, | |
| 21 | image_pipeline: wgpu::RenderPipeline, | |
| 22 | erase_pipeline: wgpu::RenderPipeline, | |
| 23 | multiply_pipeline: wgpu::RenderPipeline, | |
| 24 | atlas: wgpu::Texture, | |
| 25 | bind_group: wgpu::BindGroup, | |
| 26 | image_sampler: wgpu::Sampler, | |
| 20 | layout: wgpu::BindGroupLayout, | |
| 21 | /// A pipeline for each of `Blend::STATES`. | |
| 22 | pipelines: [wgpu::RenderPipeline; 4], | |
| 23 | atlas: (wgpu::Texture, wgpu::BindGroup), | |
| 24 | nearest: wgpu::Sampler, | |
| 25 | linear: wgpu::Sampler, | |
| 27 | 26 | vertex_buffer: wgpu::Buffer, |
| 28 | 27 | format: wgpu::TextureFormat, |
| 29 | 28 | /// Offscreen pictures for groups, the target's size, and how each is sampled. |
| 30 | groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>, | |
| 31 | groups_size: [u32; 2], | |
| 29 | groups: Vec<(wgpu::Texture, wgpu::BindGroup)>, | |
| 32 | 30 | } |
| 33 | 31 | |
| 34 | #[cfg(not(windows))] | |
| 35 | 32 | impl Renderer { |
| 36 | 33 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { |
| 37 | 34 | Self::with_gpu(Gpu::new(device, queue, format)) |
| 38 | 35 | } |
| 39 | 36 | |
| 37 | #[cfg(not(windows))] | |
| 40 | 38 | pub fn device(&self) -> &wgpu::Device { |
| 41 | 39 | &self.gpu.device |
| 42 | 40 | } |
| 43 | 41 | |
| 42 | #[cfg(not(windows))] | |
| 44 | 43 | pub fn queue(&self) -> &wgpu::Queue { |
| 45 | 44 | &self.gpu.queue |
| 46 | 45 | } |
| ... | ... | @@ -53,7 +52,7 @@ impl Gpu { |
| 53 | 52 | format: wgpu::TextureFormat, |
| 54 | 53 | ) -> Self { |
| 55 | 54 | let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); |
| 56 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 55 | let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 57 | 56 | label: Some("Draw texture"), |
| 58 | 57 | entries: &[ |
| 59 | 58 | wgpu::BindGroupLayoutEntry { |
| ... | ... | @@ -76,10 +75,23 @@ impl Gpu { |
| 76 | 75 | }); |
| 77 | 76 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { |
| 78 | 77 | label: Some("Draw"), |
| 79 | bind_group_layouts: &[Some(&binding_layout)], | |
| 78 | bind_group_layouts: &[Some(&layout)], | |
| 80 | 79 | ..Default::default() |
| 81 | 80 | }); |
| 82 | let make_pipeline = |blend| { | |
| 81 | let attributes: [_; 9] = std::array::from_fn(|index| { | |
| 82 | let (_, floats, offset) = ATTRIBUTES[index]; | |
| 83 | wgpu::VertexAttribute { | |
| 84 | format: [ | |
| 85 | wgpu::VertexFormat::Float32, | |
| 86 | wgpu::VertexFormat::Float32x2, | |
| 87 | wgpu::VertexFormat::Float32x3, | |
| 88 | wgpu::VertexFormat::Float32x4, | |
| 89 | ][floats - 1], | |
| 90 | offset: offset as u64, | |
| 91 | shader_location: index as u32, | |
| 92 | } | |
| 93 | }); | |
| 94 | let pipelines = Blend::STATES.map(|[color, dst_color, alpha, dst_alpha]| { | |
| 83 | 95 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { |
| 84 | 96 | label: Some("Draw"), |
| 85 | 97 | layout: Some(&pipeline_layout), |
| ... | ... | @@ -90,7 +102,7 @@ impl Gpu { |
| 90 | 102 | buffers: &[Some(wgpu::VertexBufferLayout { |
| 91 | 103 | array_stride: size_of::<Vertex>() as u64, |
| 92 | 104 | step_mode: wgpu::VertexStepMode::Vertex, |
| 93 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3, 8 => Float32], | |
| 105 | attributes: &attributes, | |
| 94 | 106 | })], |
| 95 | 107 | }, |
| 96 | 108 | fragment: Some(wgpu::FragmentState { |
| ... | ... | @@ -99,7 +111,10 @@ impl Gpu { |
| 99 | 111 | compilation_options: Default::default(), |
| 100 | 112 | targets: &[Some(wgpu::ColorTargetState { |
| 101 | 113 | format, |
| 102 | blend: Some(blend), | |
| 114 | blend: Some(wgpu::BlendState { | |
| 115 | color: component([color, dst_color]), | |
| 116 | alpha: component([alpha, dst_alpha]), | |
| 117 | }), | |
| 103 | 118 | write_mask: wgpu::ColorWrites::ALL, |
| 104 | 119 | })], |
| 105 | 120 | }), |
| ... | ... | @@ -109,63 +124,33 @@ impl Gpu { |
| 109 | 124 | multiview_mask: None, |
| 110 | 125 | cache: None, |
| 111 | 126 | }) |
| 112 | }; | |
| 113 | // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear. | |
| 114 | let pipeline = make_pipeline(wgpu::BlendState { | |
| 115 | color: wgpu::BlendState::ALPHA_BLENDING.color, | |
| 116 | alpha: wgpu::BlendComponent::OVER, | |
| 117 | }); | |
| 118 | let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); | |
| 119 | let erase = wgpu::BlendComponent { | |
| 120 | src_factor: wgpu::BlendFactor::Zero, | |
| 121 | dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, | |
| 122 | operation: wgpu::BlendOperation::Add, | |
| 123 | }; | |
| 124 | let erase_pipeline = make_pipeline(wgpu::BlendState { | |
| 125 | color: erase, | |
| 126 | alpha: erase, | |
| 127 | }); | |
| 128 | // The fragment's colour is scaled by its coverage, so this leaves the target times | |
| 129 | // the colour where covered and the target elsewhere; alpha stays. | |
| 130 | let multiply_pipeline = make_pipeline(wgpu::BlendState { | |
| 131 | color: wgpu::BlendComponent { | |
| 132 | src_factor: wgpu::BlendFactor::Dst, | |
| 133 | dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, | |
| 134 | operation: wgpu::BlendOperation::Add, | |
| 135 | }, | |
| 136 | alpha: wgpu::BlendComponent { | |
| 137 | src_factor: wgpu::BlendFactor::Zero, | |
| 138 | dst_factor: wgpu::BlendFactor::One, | |
| 139 | operation: wgpu::BlendOperation::Add, | |
| 140 | }, | |
| 141 | 127 | }); |
| 142 | let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE); | |
| 143 | 128 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { |
| 144 | 129 | label: Some("Draw vertices"), |
| 145 | 130 | size: VERTEX_BUFFER_BYTES, |
| 146 | 131 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, |
| 147 | 132 | mapped_at_creation: false, |
| 148 | 133 | }); |
| 149 | let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 150 | label: Some("Draw image filtering"), | |
| 151 | mag_filter: wgpu::FilterMode::Linear, | |
| 152 | min_filter: wgpu::FilterMode::Linear, | |
| 153 | ..Default::default() | |
| 154 | }); | |
| 134 | let sampler = |filter| { | |
| 135 | device.create_sampler(&wgpu::SamplerDescriptor { | |
| 136 | mag_filter: filter, | |
| 137 | min_filter: filter, | |
| 138 | ..Default::default() | |
| 139 | }) | |
| 140 | }; | |
| 141 | let nearest = sampler(wgpu::FilterMode::Nearest); | |
| 142 | let atlas = atlas(&device, &layout, &nearest, ATLAS_SIZE); | |
| 155 | 143 | Self { |
| 144 | linear: sampler(wgpu::FilterMode::Linear), | |
| 145 | nearest, | |
| 146 | atlas, | |
| 156 | 147 | device, |
| 157 | 148 | queue, |
| 158 | pipeline, | |
| 159 | image_pipeline, | |
| 160 | erase_pipeline, | |
| 161 | multiply_pipeline, | |
| 162 | atlas, | |
| 163 | bind_group, | |
| 164 | image_sampler, | |
| 149 | layout, | |
| 150 | pipelines, | |
| 165 | 151 | vertex_buffer, |
| 166 | 152 | format, |
| 167 | 153 | groups: Vec::new(), |
| 168 | groups_size: [0; 2], | |
| 169 | 154 | } |
| 170 | 155 | } |
| 171 | 156 | |
| ... | ... | @@ -178,11 +163,11 @@ impl Gpu { |
| 178 | 163 | } |
| 179 | 164 | |
| 180 | 165 | pub(super) fn atlas_side(&self) -> u32 { |
| 181 | self.atlas.width() | |
| 166 | self.atlas.0.width() | |
| 182 | 167 | } |
| 183 | 168 | |
| 184 | 169 | pub(super) fn new_atlas(&mut self, side: u32) { |
| 185 | (self.atlas, self.bind_group) = atlas(&self.device, &self.pipeline, side); | |
| 170 | self.atlas = atlas(&self.device, &self.layout, &self.nearest, side); | |
| 186 | 171 | } |
| 187 | 172 | |
| 188 | 173 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| ... | ... | @@ -193,7 +178,7 @@ impl Gpu { |
| 193 | 178 | y: origin[1], |
| 194 | 179 | z: 0, |
| 195 | 180 | }, |
| 196 | ..self.atlas.as_image_copy() | |
| 181 | ..self.atlas.0.as_image_copy() | |
| 197 | 182 | }, |
| 198 | 183 | rgba, |
| 199 | 184 | wgpu::TexelCopyBufferLayout { |
| ... | ... | @@ -210,21 +195,12 @@ impl Gpu { |
| 210 | 195 | } |
| 211 | 196 | |
| 212 | 197 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 213 | let size = wgpu::Extent3d { | |
| 214 | width: image.size[0], | |
| 215 | height: image.size[1], | |
| 216 | depth_or_array_layers: 1, | |
| 217 | }; | |
| 218 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 219 | label: Some("Draw image"), | |
| 220 | size, | |
| 221 | mip_level_count: 1, | |
| 222 | sample_count: 1, | |
| 223 | dimension: wgpu::TextureDimension::D2, | |
| 224 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 225 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 226 | view_formats: &[], | |
| 227 | }); | |
| 198 | let texture = texture( | |
| 199 | &self.device, | |
| 200 | image.size, | |
| 201 | wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 202 | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 203 | ); | |
| 228 | 204 | self.queue.write_texture( |
| 229 | 205 | texture.as_image_copy(), |
| 230 | 206 | image.pixels(), |
| ... | ... | @@ -233,66 +209,9 @@ impl Gpu { |
| 233 | 209 | bytes_per_row: Some(image.size[0] * 4), |
| 234 | 210 | rows_per_image: Some(image.size[1]), |
| 235 | 211 | }, |
| 236 | size, | |
| 212 | texture.size(), | |
| 237 | 213 | ); |
| 238 | Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 239 | label: Some("Draw image"), | |
| 240 | layout: &self.pipeline.get_bind_group_layout(0), | |
| 241 | entries: &[ | |
| 242 | wgpu::BindGroupEntry { | |
| 243 | binding: 0, | |
| 244 | resource: wgpu::BindingResource::TextureView( | |
| 245 | &texture.create_view(&Default::default()), | |
| 246 | ), | |
| 247 | }, | |
| 248 | wgpu::BindGroupEntry { | |
| 249 | binding: 1, | |
| 250 | resource: wgpu::BindingResource::Sampler(&self.image_sampler), | |
| 251 | }, | |
| 252 | ], | |
| 253 | })) | |
| 254 | } | |
| 255 | ||
| 256 | /// An offscreen picture `size` large for each group, kept for later frames. | |
| 257 | fn group_pictures(&mut self, size: [u32; 2], count: usize) { | |
| 258 | let gpu = self; | |
| 259 | if gpu.groups_size != size { | |
| 260 | gpu.groups.clear(); | |
| 261 | gpu.groups_size = size; | |
| 262 | } | |
| 263 | while gpu.groups.len() < count { | |
| 264 | let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { | |
| 265 | label: Some("Draw group"), | |
| 266 | size: wgpu::Extent3d { | |
| 267 | width: size[0], | |
| 268 | height: size[1], | |
| 269 | depth_or_array_layers: 1, | |
| 270 | }, | |
| 271 | mip_level_count: 1, | |
| 272 | sample_count: 1, | |
| 273 | dimension: wgpu::TextureDimension::D2, | |
| 274 | format: gpu.format, | |
| 275 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | |
| 276 | | wgpu::TextureUsages::TEXTURE_BINDING, | |
| 277 | view_formats: &[], | |
| 278 | }); | |
| 279 | let view = texture.create_view(&Default::default()); | |
| 280 | let binding = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 281 | label: Some("Draw group"), | |
| 282 | layout: &gpu.pipeline.get_bind_group_layout(0), | |
| 283 | entries: &[ | |
| 284 | wgpu::BindGroupEntry { | |
| 285 | binding: 0, | |
| 286 | resource: wgpu::BindingResource::TextureView(&view), | |
| 287 | }, | |
| 288 | wgpu::BindGroupEntry { | |
| 289 | binding: 1, | |
| 290 | resource: wgpu::BindingResource::Sampler(&gpu.image_sampler), | |
| 291 | }, | |
| 292 | ], | |
| 293 | }); | |
| 294 | gpu.groups.push((view, binding)); | |
| 295 | } | |
| 214 | Image(binding(&self.device, &self.layout, &texture, &self.linear)) | |
| 296 | 215 | } |
| 297 | 216 | |
| 298 | 217 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| ... | ... | @@ -304,30 +223,28 @@ impl Gpu { |
| 304 | 223 | size: [u32; 2], |
| 305 | 224 | clear: [f32; 4], |
| 306 | 225 | ) { |
| 307 | self.group_pictures(size, frame.groups.len()); | |
| 226 | if self | |
| 227 | .groups | |
| 228 | .first() | |
| 229 | .is_some_and(|(picture, _)| [picture.width(), picture.height()] != size) | |
| 230 | { | |
| 231 | self.groups.clear(); | |
| 232 | } | |
| 233 | while self.groups.len() < frame.groups.len() { | |
| 234 | let picture = texture( | |
| 235 | &self.device, | |
| 236 | size, | |
| 237 | self.format, | |
| 238 | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, | |
| 239 | ); | |
| 240 | let binding = binding(&self.device, &self.layout, &picture, &self.linear); | |
| 241 | self.groups.push((picture, binding)); | |
| 242 | } | |
| 308 | 243 | let gpu = &*self; |
| 309 | 244 | gpu.queue |
| 310 | 245 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); |
| 311 | 246 | let mut encoder = gpu.device.create_command_encoder(&Default::default()); |
| 312 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { | |
| 313 | for batch in batches { | |
| 314 | let [x, y, width, height] = batch.scissor; | |
| 315 | pass.set_scissor_rect(x, y, width, height); | |
| 316 | let (pipeline, binding) = match batch.blend { | |
| 317 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), | |
| 318 | Blend::Image(id) => { | |
| 319 | let image: &Image = frame.images[&id].texture.as_ref(); | |
| 320 | (&gpu.image_pipeline, &image.0) | |
| 321 | } | |
| 322 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), | |
| 323 | Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), | |
| 324 | }; | |
| 325 | pass.set_pipeline(pipeline); | |
| 326 | pass.set_bind_group(0, binding, &[]); | |
| 327 | pass.draw(batch.vertices.clone(), 0..1); | |
| 328 | } | |
| 329 | }; | |
| 330 | let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| { | |
| 247 | let mut paint = |view: &wgpu::TextureView, clear: [f32; 4], batches: &[Batch]| { | |
| 331 | 248 | let [r, g, b, a] = clear.map(f64::from); |
| 332 | 249 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { |
| 333 | 250 | label: Some("Draw"), |
| ... | ... | @@ -343,67 +260,102 @@ impl Gpu { |
| 343 | 260 | ..Default::default() |
| 344 | 261 | }); |
| 345 | 262 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); |
| 346 | pass.forget_lifetime() | |
| 263 | for batch in batches { | |
| 264 | let [x, y, width, height] = batch.scissor; | |
| 265 | pass.set_scissor_rect(x, y, width, height); | |
| 266 | pass.set_pipeline(&gpu.pipelines[batch.blend.state()]); | |
| 267 | let binding = match batch.blend { | |
| 268 | Blend::Image(id) => &frame.image::<Image>(id).0, | |
| 269 | Blend::Group(index) => &gpu.groups[index].1, | |
| 270 | Blend::Over | Blend::Erase | Blend::Multiply => &gpu.atlas.1, | |
| 271 | }; | |
| 272 | pass.set_bind_group(0, binding, &[]); | |
| 273 | pass.draw(batch.vertices.clone(), 0..1); | |
| 274 | } | |
| 347 | 275 | }; |
| 348 | for (group, (view, _)) in frame.groups.iter().zip(&gpu.groups) { | |
| 349 | let mut pass = begin(&mut encoder, view, [0.0; 4]); | |
| 350 | draw(&mut pass, &frame.batches[group.batches.clone()]); | |
| 276 | for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) { | |
| 277 | paint(&picture.create_view(&Default::default()), [0.0; 4], batches); | |
| 351 | 278 | } |
| 352 | let mut pass = begin(&mut encoder, target, clear); | |
| 353 | let mut next = 0; | |
| 354 | for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) { | |
| 355 | draw(&mut pass, &frame.batches[next..group.batches.start]); | |
| 356 | pass.set_scissor_rect(0, 0, size[0], size[1]); | |
| 357 | pass.set_pipeline(&gpu.image_pipeline); | |
| 358 | pass.set_bind_group(0, binding, &[]); | |
| 359 | pass.draw(group.composite.clone(), 0..1); | |
| 360 | next = group.batches.end; | |
| 361 | } | |
| 362 | draw(&mut pass, &frame.batches[next..]); | |
| 363 | drop(pass); | |
| 279 | paint(target, clear, frame.batches); | |
| 364 | 280 | gpu.queue.submit([encoder.finish()]); |
| 365 | 281 | } |
| 366 | 282 | } |
| 367 | 283 | |
| 368 | /// An empty atlas `side` texels square, and how the pipeline samples it. | |
| 369 | fn atlas( | |
| 284 | fn component([source, destination]: [Factor; 2]) -> wgpu::BlendComponent { | |
| 285 | let factor = |factor| match factor { | |
| 286 | Factor::Zero => wgpu::BlendFactor::Zero, | |
| 287 | Factor::One => wgpu::BlendFactor::One, | |
| 288 | Factor::SourceAlpha => wgpu::BlendFactor::SrcAlpha, | |
| 289 | Factor::OneMinusSourceAlpha => wgpu::BlendFactor::OneMinusSrcAlpha, | |
| 290 | Factor::Destination => wgpu::BlendFactor::Dst, | |
| 291 | }; | |
| 292 | wgpu::BlendComponent { | |
| 293 | src_factor: factor(source), | |
| 294 | dst_factor: factor(destination), | |
| 295 | operation: wgpu::BlendOperation::Add, | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | fn texture( | |
| 370 | 300 | device: &wgpu::Device, |
| 371 | pipeline: &wgpu::RenderPipeline, | |
| 372 | side: u32, | |
| 373 | ) -> (wgpu::Texture, wgpu::BindGroup) { | |
| 374 | let atlas = device.create_texture(&wgpu::TextureDescriptor { | |
| 375 | label: Some("Glyph atlas"), | |
| 301 | [width, height]: [u32; 2], | |
| 302 | format: wgpu::TextureFormat, | |
| 303 | usage: wgpu::TextureUsages, | |
| 304 | ) -> wgpu::Texture { | |
| 305 | device.create_texture(&wgpu::TextureDescriptor { | |
| 306 | label: Some("Draw"), | |
| 376 | 307 | size: wgpu::Extent3d { |
| 377 | width: side, | |
| 378 | height: side, | |
| 308 | width, | |
| 309 | height, | |
| 379 | 310 | depth_or_array_layers: 1, |
| 380 | 311 | }, |
| 381 | 312 | mip_level_count: 1, |
| 382 | 313 | sample_count: 1, |
| 383 | 314 | dimension: wgpu::TextureDimension::D2, |
| 384 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 385 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 315 | format, | |
| 316 | usage, | |
| 386 | 317 | view_formats: &[], |
| 387 | }); | |
| 388 | let view = atlas.create_view(&Default::default()); | |
| 389 | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 390 | mag_filter: wgpu::FilterMode::Nearest, | |
| 391 | min_filter: wgpu::FilterMode::Nearest, | |
| 392 | ..Default::default() | |
| 393 | }); | |
| 394 | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 395 | label: Some("Glyph atlas"), | |
| 396 | layout: &pipeline.get_bind_group_layout(0), | |
| 318 | }) | |
| 319 | } | |
| 320 | ||
| 321 | /// How the pipelines sample `texture` through `sampler`. | |
| 322 | fn binding( | |
| 323 | device: &wgpu::Device, | |
| 324 | layout: &wgpu::BindGroupLayout, | |
| 325 | texture: &wgpu::Texture, | |
| 326 | sampler: &wgpu::Sampler, | |
| 327 | ) -> wgpu::BindGroup { | |
| 328 | device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 329 | label: Some("Draw"), | |
| 330 | layout, | |
| 397 | 331 | entries: &[ |
| 398 | 332 | wgpu::BindGroupEntry { |
| 399 | 333 | binding: 0, |
| 400 | resource: wgpu::BindingResource::TextureView(&view), | |
| 334 | resource: wgpu::BindingResource::TextureView( | |
| 335 | &texture.create_view(&Default::default()), | |
| 336 | ), | |
| 401 | 337 | }, |
| 402 | 338 | wgpu::BindGroupEntry { |
| 403 | 339 | binding: 1, |
| 404 | resource: wgpu::BindingResource::Sampler(&sampler), | |
| 340 | resource: wgpu::BindingResource::Sampler(sampler), | |
| 405 | 341 | }, |
| 406 | 342 | ], |
| 407 | }); | |
| 408 | (atlas, bind_group) | |
| 343 | }) | |
| 344 | } | |
| 345 | ||
| 346 | /// An empty atlas `side` texels square, and how the pipelines sample it. | |
| 347 | fn atlas( | |
| 348 | device: &wgpu::Device, | |
| 349 | layout: &wgpu::BindGroupLayout, | |
| 350 | nearest: &wgpu::Sampler, | |
| 351 | side: u32, | |
| 352 | ) -> (wgpu::Texture, wgpu::BindGroup) { | |
| 353 | let atlas = texture( | |
| 354 | device, | |
| 355 | [side; 2], | |
| 356 | wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 357 | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 358 | ); | |
| 359 | let binding = binding(device, layout, &atlas, nearest); | |
| 360 | (atlas, binding) | |
| 409 | 361 | } |
crates/mobile/Cargo.toml+2| ... | ... | @@ -19,6 +19,8 @@ pollster = "0.4" |
| 19 | 19 | resvg = { version = "0.48.1", default-features = false } |
| 20 | 20 | serde = { version = "1", features = ["derive"] } |
| 21 | 21 | serde_json = "1" |
| 22 | # Clears a password handed in to unlock a section. | |
| 23 | zeroize = "1.9.0" | |
| 22 | 24 | |
| 23 | 25 | [target.'cfg(target_os = "ios")'.dependencies] |
| 24 | 26 | objc2 = "0.5.2" |
crates/mobile/include/snowbound.h+5| ... | ... | @@ -33,6 +33,8 @@ char *sb_library_new_section(const Library *library, const char *folder, const c |
| 33 | 33 | const char *date, const char *time); |
| 34 | 34 | char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); |
| 35 | 35 | char *sb_library_tagged(const Library *library, const Section *open, const char *path); |
| 36 | bool sb_library_unlock(const Library *library, const char *path, const char *password); | |
| 37 | void sb_library_lock_all(const Library *library); | |
| 36 | 38 | char *sb_library_sync_status(const Library *library); |
| 37 | 39 | void sb_library_set_offline(const Library *library, bool offline); |
| 38 | 40 | void sb_library_touched(const Library *library, const char *path); |
| ... | ... | @@ -56,6 +58,7 @@ char *sb_section_themes(const Section *section, const char *id); |
| 56 | 58 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); |
| 57 | 59 | bool sb_section_flush(const Section *section, double seconds); |
| 58 | 60 | void sb_section_wake(const Section *section); |
| 61 | void sb_section_set_author(const Section *section, const char *author); | |
| 59 | 62 | void sb_section_free(Section *section); |
| 60 | 63 | |
| 61 | 64 | View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); |
| ... | ... | @@ -65,6 +68,8 @@ bool sb_view_render(View *view); |
| 65 | 68 | bool sb_view_frame_pending(const View *view); |
| 66 | 69 | void sb_view_set_dark(View *view, bool dark); |
| 67 | 70 | bool sb_view_read_only(const View *view); |
| 71 | char *sb_view_reading(const View *view, float width); | |
| 72 | bool sb_view_set_reading(View *view, float width); | |
| 68 | 73 | bool sb_view_reload(View *view, bool discard); |
| 69 | 74 | void sb_view_focus(View *view, bool focused); |
| 70 | 75 | void sb_view_content(View *view, float bounds[4]); |
crates/mobile/src/lib.rs+104-55| ... | ... | @@ -37,7 +37,7 @@ use std::{ |
| 37 | 37 | ffi::{CStr, CString, c_char, c_void}, |
| 38 | 38 | mem::ManuallyDrop, |
| 39 | 39 | sync::{ |
| 40 | Arc, Mutex, | |
| 40 | Arc, LazyLock, Mutex, MutexGuard, | |
| 41 | 41 | atomic::{AtomicBool, Ordering}, |
| 42 | 42 | }, |
| 43 | 43 | task::{Wake, Waker}, |
| ... | ... | @@ -125,29 +125,19 @@ impl Canvas { |
| 125 | 125 | /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. |
| 126 | 126 | fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> { |
| 127 | 127 | let page = self.page.editor.page().ok()?; |
| 128 | let mut engine = ENGINES | |
| 129 | .lock() | |
| 130 | .ok() | |
| 131 | .and_then(|mut engines| engines.pop()) | |
| 132 | .unwrap_or_default(); | |
| 128 | let mut engine = pooled_engine(); | |
| 133 | 129 | let setup = canvas::print::Setup { |
| 134 | 130 | paper, |
| 135 | 131 | fit_width: true, |
| 136 | 132 | footer: canvas::print::Footer::SectionAndPage, |
| 137 | 133 | }; |
| 138 | 134 | let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); |
| 139 | if let Ok(mut engines) = ENGINES.lock() { | |
| 140 | engines.push(engine); | |
| 141 | } | |
| 135 | pool_engine(engine); | |
| 142 | 136 | pdf.ok() |
| 143 | 137 | } |
| 144 | 138 | |
| 145 | 139 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { |
| 146 | let mut engine = ENGINES | |
| 147 | .lock() | |
| 148 | .ok() | |
| 149 | .and_then(|mut engines| engines.pop()) | |
| 150 | .unwrap_or_default(); | |
| 140 | let mut engine = pooled_engine(); | |
| 151 | 141 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; |
| 152 | 142 | let mut page = PageView::new( |
| 153 | 143 | editor, |
| ... | ... | @@ -159,7 +149,7 @@ impl Canvas { |
| 159 | 149 | ); |
| 160 | 150 | page.touch = true; |
| 161 | 151 | page.host_viewport = true; |
| 162 | (page.snap_to_grid, page.editor.default_font) = options(); | |
| 152 | (page.snap_to_grid, page.editor.default_font) = options().clone(); | |
| 163 | 153 | Ok(Self { |
| 164 | 154 | page, |
| 165 | 155 | space, |
| ... | ... | @@ -198,19 +188,7 @@ impl Canvas { |
| 198 | 188 | /// What the page took since the last call as one edit, or none when it took nothing. |
| 199 | 189 | fn edit(&mut self) -> Result<Option<Edit>> { |
| 200 | 190 | let ops = self.page.editor.take_ops()?; |
| 201 | if ops.is_empty() { | |
| 202 | return Ok(None); | |
| 203 | } | |
| 204 | Ok(Some(Edit { | |
| 205 | at: library::filetime(), | |
| 206 | ops: ops | |
| 207 | .into_iter() | |
| 208 | .map(|op| Op::Page { | |
| 209 | space: self.space, | |
| 210 | op, | |
| 211 | }) | |
| 212 | .collect(), | |
| 213 | })) | |
| 191 | Ok((!ops.is_empty()).then(|| page_edit(self.space, ops))) | |
| 214 | 192 | } |
| 215 | 193 | |
| 216 | 194 | fn device(&self, point: [f32; 2]) -> [f32; 2] { |
| ... | ... | @@ -633,23 +611,33 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { |
| 633 | 611 | /// rather than enumerating the system's fonts again. |
| 634 | 612 | static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new()); |
| 635 | 613 | |
| 614 | fn pooled_engine() -> TextEngine { | |
| 615 | ENGINES | |
| 616 | .lock() | |
| 617 | .ok() | |
| 618 | .and_then(|mut engines| engines.pop()) | |
| 619 | .unwrap_or_default() | |
| 620 | } | |
| 621 | ||
| 622 | fn pool_engine(engine: TextEngine) { | |
| 623 | if let Ok(mut engines) = ENGINES.lock() { | |
| 624 | engines.push(engine); | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 636 | 628 | /// The reader's Snap To Grid and Default font, which every page opened from then on takes, |
| 637 | 629 | /// and new pages' titles the font's face. |
| 638 | static OPTIONS: Mutex<Option<(bool, canvas::editor::DefaultFont)>> = Mutex::new(None); | |
| 630 | static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont)>> = | |
| 631 | LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default()))); | |
| 639 | 632 | |
| 640 | fn options() -> (bool, canvas::editor::DefaultFont) { | |
| 641 | OPTIONS | |
| 642 | .lock() | |
| 643 | .unwrap_or_else(|error| error.into_inner()) | |
| 644 | .clone() | |
| 645 | .unwrap_or((true, canvas::editor::DefaultFont::default())) | |
| 633 | fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> { | |
| 634 | OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) | |
| 646 | 635 | } |
| 647 | 636 | |
| 648 | 637 | /// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. |
| 649 | 638 | #[unsafe(no_mangle)] |
| 650 | 639 | pub extern "C" fn sb_set_snap_to_grid(on: bool) { |
| 651 | let font = options().1; | |
| 652 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font)); | |
| 640 | options().0 = on; | |
| 653 | 641 | } |
| 654 | 642 | |
| 655 | 643 | /// OneNote's Default font: new text in `face` at `size` points, new titles in `face`. |
| ... | ... | @@ -658,13 +646,11 @@ pub extern "C" fn sb_set_snap_to_grid(on: bool) { |
| 658 | 646 | /// `face` is NUL-terminated UTF-8. |
| 659 | 647 | #[unsafe(no_mangle)] |
| 660 | 648 | pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { |
| 661 | let snap = options().0; | |
| 662 | let font = canvas::editor::DefaultFont { | |
| 649 | options().1 = canvas::editor::DefaultFont { | |
| 663 | 650 | face: string(face), |
| 664 | 651 | size, |
| 665 | 652 | color: None, |
| 666 | 653 | }; |
| 667 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font)); | |
| 668 | 654 | } |
| 669 | 655 | |
| 670 | 656 | pub struct View { |
| ... | ... | @@ -676,6 +662,8 @@ pub struct View { |
| 676 | 662 | section: Arc<Shared>, |
| 677 | 663 | /// A conflict page, which shows what is stored and takes no edits. |
| 678 | 664 | read_only: bool, |
| 665 | /// The column, in page points, a reading view reflows the page into; it takes no edits. | |
| 666 | reading: Option<f32>, | |
| 679 | 667 | } |
| 680 | 668 | |
| 681 | 669 | impl View { |
| ... | ... | @@ -731,13 +719,7 @@ impl View { |
| 731 | 719 | .unwrap_or_default(); |
| 732 | 720 | let restyle = onestore::op::restyle(&page, &sheet)?; |
| 733 | 721 | if !read_only && !restyle.is_empty() { |
| 734 | section.shared.apply(Edit { | |
| 735 | at: library::filetime(), | |
| 736 | ops: restyle | |
| 737 | .into_iter() | |
| 738 | .map(|op| Op::Page { space, op }) | |
| 739 | .collect(), | |
| 740 | })?; | |
| 722 | section.shared.apply(page_edit(space, restyle))?; | |
| 741 | 723 | page = section.shared.page(space)?.0; |
| 742 | 724 | } |
| 743 | 725 | let frame = Arc::<Frame>::default(); |
| ... | ... | @@ -757,6 +739,7 @@ impl View { |
| 757 | 739 | frame, |
| 758 | 740 | section: Arc::clone(&section.shared), |
| 759 | 741 | read_only, |
| 742 | reading: None, | |
| 760 | 743 | }) |
| 761 | 744 | } |
| 762 | 745 | |
| ... | ... | @@ -795,7 +778,7 @@ impl View { |
| 795 | 778 | fn stored(&mut self, result: Result<bool>) -> bool { |
| 796 | 779 | let changed = report(result).unwrap_or(false); |
| 797 | 780 | match report(self.canvas.edit()) { |
| 798 | Some(Some(_)) if self.read_only => { | |
| 781 | Some(Some(_)) if self.read_only || self.reading.is_some() => { | |
| 799 | 782 | let _ = report(self.reload()); |
| 800 | 783 | } |
| 801 | 784 | Some(Some(edit)) => { |
| ... | ... | @@ -830,17 +813,54 @@ impl View { |
| 830 | 813 | } |
| 831 | 814 | let (page, read_only) = self.section.page(self.canvas.space)?; |
| 832 | 815 | self.read_only = read_only; |
| 816 | if let Some(column) = self.reading { | |
| 817 | return self.show(page, Some(column)); | |
| 818 | } | |
| 833 | 819 | Ok(moved(self.canvas.page.refresh(page)?)) |
| 834 | 820 | } |
| 821 | ||
| 822 | /// Shows `page` anew, reflowed into `column` where given and the page reflows; whether | |
| 823 | /// it shows reflowed. | |
| 824 | fn show(&mut self, page: Page, column: Option<f32>) -> Result<bool> { | |
| 825 | let mut engine = pooled_engine(); | |
| 826 | let reflowed = match column { | |
| 827 | Some(column) => canvas::reading::read(&page, column, &mut engine)?.page, | |
| 828 | None => None, | |
| 829 | }; | |
| 830 | pool_engine(engine); | |
| 831 | self.reading = column.filter(|_| reflowed.is_some()); | |
| 832 | let pixels = [self.config.width, self.config.height]; | |
| 833 | let mut canvas = Canvas::new( | |
| 834 | self.canvas.space, | |
| 835 | reflowed.unwrap_or(page), | |
| 836 | pixels, | |
| 837 | self.canvas.scale, | |
| 838 | )?; | |
| 839 | canvas.paper = self.canvas.paper; | |
| 840 | canvas.page.spelling = self.canvas.page.spelling.take(); | |
| 841 | let _ = canvas.page.focus_changed(false)?; | |
| 842 | canvas.page.editor.styles = std::mem::take(&mut self.canvas.page.editor.styles); | |
| 843 | // SAFETY: `self.canvas` is replaced at once and not used in between. | |
| 844 | let old = unsafe { ManuallyDrop::take(&mut self.canvas) }; | |
| 845 | self.canvas = ManuallyDrop::new(canvas); | |
| 846 | pool_engine(old.page.engine); | |
| 847 | Ok(self.reading.is_some()) | |
| 848 | } | |
| 835 | 849 | } |
| 836 | 850 | |
| 837 | 851 | impl Drop for View { |
| 838 | 852 | fn drop(&mut self) { |
| 839 | 853 | // SAFETY: `canvas` is not used again. |
| 840 | 854 | let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) }; |
| 841 | if let Ok(mut engines) = ENGINES.lock() { | |
| 842 | engines.push(canvas.page.engine); | |
| 843 | } | |
| 855 | pool_engine(canvas.page.engine); | |
| 856 | } | |
| 857 | } | |
| 858 | ||
| 859 | /// What a page took as one edit of the page in `space`, made now. | |
| 860 | fn page_edit(space: ExGuid, ops: Vec<PageOp>) -> Edit { | |
| 861 | Edit { | |
| 862 | at: library::filetime(), | |
| 863 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), | |
| 844 | 864 | } |
| 845 | 865 | } |
| 846 | 866 | |
| ... | ... | @@ -918,10 +938,39 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { |
| 918 | 938 | view.canvas.set_dark(dark); |
| 919 | 939 | } |
| 920 | 940 | |
| 921 | /// A conflict page: it shows what is stored, and any edit returns it there. | |
| 941 | /// A conflict page or a reading view: it shows what is stored, and any edit returns it there. | |
| 922 | 942 | #[unsafe(no_mangle)] |
| 923 | 943 | pub extern "C" fn sb_view_read_only(view: &View) -> bool { |
| 924 | view.read_only | |
| 944 | view.read_only || view.reading.is_some() | |
| 945 | } | |
| 946 | ||
| 947 | /// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the | |
| 948 | /// `verdict` saying how it reflows or why not. | |
| 949 | #[unsafe(no_mangle)] | |
| 950 | pub extern "C" fn sb_view_reading(view: &View, width: f32) -> *mut c_char { | |
| 951 | let result = view.section.page(view.canvas.space).and_then(|(page, _)| { | |
| 952 | let mut engine = pooled_engine(); | |
| 953 | let read = canvas::reading::read(&page, width / POINT, &mut engine); | |
| 954 | pool_engine(engine); | |
| 955 | Ok(read?.verdict) | |
| 956 | }); | |
| 957 | report(result).map_or(std::ptr::null_mut(), |verdict| { | |
| 958 | let json = | |
| 959 | serde_json::json!({ "offered": verdict.offered(), "verdict": format!("{verdict:?}") }); | |
| 960 | owned(json.to_string()) | |
| 961 | }) | |
| 962 | } | |
| 963 | ||
| 964 | /// Shows the page reflowed into a column `width` points wide, read-only, or with 0 as laid | |
| 965 | /// out; whether it now shows reflowed. | |
| 966 | #[unsafe(no_mangle)] | |
| 967 | pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32) -> bool { | |
| 968 | let column = (width > 0.0).then_some(width / POINT); | |
| 969 | let shown = view | |
| 970 | .section | |
| 971 | .page(view.canvas.space) | |
| 972 | .and_then(|(page, _)| view.show(page, column)); | |
| 973 | report(shown).unwrap_or(false) | |
| 925 | 974 | } |
| 926 | 975 | |
| 927 | 976 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with |
| ... | ... | @@ -1234,7 +1283,7 @@ struct Choices { |
| 1234 | 1283 | templates: Vec<&'static str>, |
| 1235 | 1284 | } |
| 1236 | 1285 | |
| 1237 | fn srgb(colorref: u32) -> [u8; 3] { | |
| 1286 | pub(crate) fn srgb(colorref: u32) -> [u8; 3] { | |
| 1238 | 1287 | let [red, green, blue, _] = colorref.to_le_bytes(); |
| 1239 | 1288 | [red, green, blue] |
| 1240 | 1289 | } |
| ... | ... | @@ -1399,7 +1448,7 @@ pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 |
| 1399 | 1448 | let Some(date) = view.canvas.page.editor.date() else { |
| 1400 | 1449 | return -1; |
| 1401 | 1450 | }; |
| 1402 | *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600; | |
| 1451 | *seconds = library::unix(date.timestamp()); | |
| 1403 | 1452 | field as i8 |
| 1404 | 1453 | } |
| 1405 | 1454 |
crates/mobile/src/library.rs+167-82| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | //! SMB share through the embedded client. A section opens through its replica in the app's |
| 3 | 3 | //! cache, which stores each edit at once and publishes it in the background. |
| 4 | 4 | |
| 5 | use crate::{Result, owned, report, string}; | |
| 5 | use crate::{Result, owned, report, srgb, string}; | |
| 6 | 6 | use canvas::search::{Entry, Index, Query, Tagged}; |
| 7 | 7 | use notebook::{ |
| 8 | 8 | Remote, Replica, |
| ... | ... | @@ -14,6 +14,7 @@ use onestore::{ |
| 14 | 14 | CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, |
| 15 | 15 | op::{Edit, Op, SectionOp}, |
| 16 | 16 | page::Page, |
| 17 | protected::Key, | |
| 17 | 18 | }; |
| 18 | 19 | use std::{ |
| 19 | 20 | collections::{BTreeMap, HashMap}, |
| ... | ... | @@ -249,6 +250,15 @@ pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) { |
| 249 | 250 | } |
| 250 | 251 | } |
| 251 | 252 | |
| 253 | /// Catalog `path` from the top of the share holding a notebook at `root`. | |
| 254 | pub(crate) fn on_share(root: &str, path: &str) -> String { | |
| 255 | if root.is_empty() { | |
| 256 | path.to_owned() | |
| 257 | } else { | |
| 258 | format!("{root}/{path}") | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 252 | 262 | /// When a section's file was last checked for changes, and how it was then. |
| 253 | 263 | struct Checked { |
| 254 | 264 | at: Instant, |
| ... | ... | @@ -267,6 +277,8 @@ pub struct Library { |
| 267 | 277 | open: Mutex<Vec<(String, Weak<Shared>)>>, |
| 268 | 278 | /// Work Offline, which sections opened later follow too. |
| 269 | 279 | offline: AtomicBool, |
| 280 | /// Keys of the protected sections unlocked this run, by catalog path; in memory only. | |
| 281 | keys: Mutex<HashMap<String, Key>>, | |
| 270 | 282 | /// How long edits wait for a pause in typing before they publish (`Section::set_pause`): |
| 271 | 283 | /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent |
| 272 | 284 | /// with another device's a conflict version. |
| ... | ... | @@ -285,8 +297,12 @@ pub(crate) struct Tab { |
| 285 | 297 | pub(crate) group: String, |
| 286 | 298 | /// The tab colour in sRGB. |
| 287 | 299 | pub(crate) color: [u8; 3], |
| 288 | /// Password-protected or unreadable sections list but do not open. | |
| 300 | /// Password-protected or unreadable sections list but do not open; a protected one opens | |
| 301 | /// once unlocked. | |
| 289 | 302 | pub(crate) readable: bool, |
| 303 | /// Password protected and not unlocked (`sb_library_unlock`). | |
| 304 | #[serde(default)] | |
| 305 | pub(crate) locked: bool, | |
| 290 | 306 | /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it. |
| 291 | 307 | #[serde(default)] |
| 292 | 308 | pub(crate) downloading: bool, |
| ... | ... | @@ -295,9 +311,9 @@ pub(crate) struct Tab { |
| 295 | 311 | pub(crate) problem: Option<String>, |
| 296 | 312 | } |
| 297 | 313 | |
| 298 | fn rgb(colorref: u32) -> [u8; 3] { | |
| 299 | let [red, green, blue, _] = colorref.to_le_bytes(); | |
| 300 | [red, green, blue] | |
| 314 | /// Titles keep OneNote's line breaks, which a one-line list shows as spaces. | |
| 315 | fn one_line(title: &str) -> String { | |
| 316 | title.replace(|char: char| char.is_control(), " ") | |
| 301 | 317 | } |
| 302 | 318 | |
| 303 | 319 | fn stem(path: &str) -> String { |
| ... | ... | @@ -309,18 +325,21 @@ fn stem(path: &str) -> String { |
| 309 | 325 | |
| 310 | 326 | /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps |
| 311 | 327 | /// deleted sections and pages in. |
| 312 | fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { | |
| 328 | fn tabs(folder: &Folder, unlocked: &HashMap<String, Key>, tabs: &mut Vec<Tab>) { | |
| 313 | 329 | for section in &folder.sections { |
| 314 | 330 | let (name, color, readable) = match &section.state { |
| 315 | 331 | SectionState::Readable { name, color, .. } => (name.clone(), *color, true), |
| 316 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), | |
| 332 | SectionState::Locked => (None, None, unlocked.contains_key(&section.path)), | |
| 333 | SectionState::Unreadable(_) => (None, None, false), | |
| 317 | 334 | }; |
| 335 | let locked = matches!(section.state, SectionState::Locked) && !readable; | |
| 318 | 336 | tabs.push(Tab { |
| 319 | 337 | name: name.unwrap_or_else(|| stem(&section.path)), |
| 320 | 338 | path: section.path.clone(), |
| 321 | 339 | group: folder.path.clone(), |
| 322 | color: rgb(color.unwrap_or(SECTION_COLOR)), | |
| 340 | color: srgb(color.unwrap_or(SECTION_COLOR)), | |
| 323 | 341 | readable, |
| 342 | locked, | |
| 324 | 343 | downloading: false, |
| 325 | 344 | problem: None, |
| 326 | 345 | }); |
| ... | ... | @@ -330,8 +349,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 330 | 349 | name: stem(&entry.path), |
| 331 | 350 | path: entry.path.clone(), |
| 332 | 351 | group: folder.path.clone(), |
| 333 | color: rgb(SECTION_COLOR), | |
| 352 | color: srgb(SECTION_COLOR), | |
| 334 | 353 | readable: false, |
| 354 | locked: false, | |
| 335 | 355 | downloading: entry.reason == Reason::Evicted, |
| 336 | 356 | problem: match entry.reason { |
| 337 | 357 | Reason::InUse => Some("Section in use".into()), |
| ... | ... | @@ -342,7 +362,7 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 342 | 362 | } |
| 343 | 363 | for group in &folder.groups { |
| 344 | 364 | if !group.path.ends_with("OneNote_RecycleBin") { |
| 345 | self::tabs(group, tabs); | |
| 365 | self::tabs(group, unlocked, tabs); | |
| 346 | 366 | } |
| 347 | 367 | } |
| 348 | 368 | } |
| ... | ... | @@ -377,6 +397,7 @@ impl Library { |
| 377 | 397 | background, |
| 378 | 398 | open: Mutex::default(), |
| 379 | 399 | offline: AtomicBool::new(false), |
| 400 | keys: Default::default(), | |
| 380 | 401 | pause: if local { Duration::ZERO } else { PAUSE }, |
| 381 | 402 | }) |
| 382 | 403 | } |
| ... | ... | @@ -399,6 +420,7 @@ impl Library { |
| 399 | 420 | background: Some(background), |
| 400 | 421 | open: Mutex::default(), |
| 401 | 422 | offline: AtomicBool::new(false), |
| 423 | keys: Default::default(), | |
| 402 | 424 | pause: Duration::ZERO, |
| 403 | 425 | }; |
| 404 | 426 | let reached = library.with_notebook(false, |_| Ok(())); |
| ... | ... | @@ -501,6 +523,24 @@ impl Library { |
| 501 | 523 | change(reopened) |
| 502 | 524 | } |
| 503 | 525 | |
| 526 | fn unlocked(&self) -> std::sync::MutexGuard<'_, HashMap<String, Key>> { | |
| 527 | self.keys.lock().unwrap_or_else(|error| error.into_inner()) | |
| 528 | } | |
| 529 | ||
| 530 | /// Unlocks the protected section at catalog `path` with `password`; whether it matched. | |
| 531 | pub(crate) fn unlock(&self, path: &str, password: &str) -> Result<bool> { | |
| 532 | let key = self.with_notebook(false, |notebook| match notebook.unlock(path, password) { | |
| 533 | Ok(key) => Ok(Some(key)), | |
| 534 | Err(notebook::Error::Protected(_)) => Ok(None), | |
| 535 | Err(error) => Err(error.into()), | |
| 536 | })?; | |
| 537 | let unlocked = key.is_some(); | |
| 538 | if let Some(key) = key { | |
| 539 | self.unlocked().insert(path.to_owned(), key); | |
| 540 | } | |
| 541 | Ok(unlocked) | |
| 542 | } | |
| 543 | ||
| 504 | 544 | /// The notebook's sections, read again. |
| 505 | 545 | pub(crate) fn tabs(&self) -> Result<Vec<Tab>> { |
| 506 | 546 | if let Place::File(file) = &self.place { |
| ... | ... | @@ -509,8 +549,9 @@ impl Library { |
| 509 | 549 | name: stem(&path), |
| 510 | 550 | path, |
| 511 | 551 | group: String::new(), |
| 512 | color: rgb(SECTION_COLOR), | |
| 552 | color: srgb(SECTION_COLOR), | |
| 513 | 553 | readable: true, |
| 554 | locked: false, | |
| 514 | 555 | downloading: false, |
| 515 | 556 | problem: None, |
| 516 | 557 | }]); |
| ... | ... | @@ -521,7 +562,7 @@ impl Library { |
| 521 | 562 | tabs: Vec::new(), |
| 522 | 563 | files: BTreeMap::new(), |
| 523 | 564 | }; |
| 524 | tabs(notebook.catalog(), &mut listed.tabs); | |
| 565 | tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs); | |
| 525 | 566 | files(notebook.catalog(), &mut listed.files); |
| 526 | 567 | if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { |
| 527 | 568 | background.watch(notebook.replicas()); |
| ... | ... | @@ -586,25 +627,36 @@ impl Library { |
| 586 | 627 | |file| Ok(Coordinated(file.to_owned())), |
| 587 | 628 | notify, |
| 588 | 629 | )?), |
| 589 | Place::Folder(_) => Ok(self | |
| 590 | .notebook() | |
| 591 | .as_ref() | |
| 592 | .ok_or("A lone section has no notebook")? | |
| 593 | .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?), | |
| 630 | Place::Folder(_) => { | |
| 631 | let notebook = self.notebook(); | |
| 632 | let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?; | |
| 633 | let connect = |file: &Path| Ok(Coordinated(file.to_owned())); | |
| 634 | Ok(match self.unlocked().get(path) { | |
| 635 | Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, | |
| 636 | None => notebook.section_with(path, connect, notify)?, | |
| 637 | }) | |
| 638 | } | |
| 594 | 639 | Place::Share { server, root, .. } => { |
| 595 | let file = if root.is_empty() { | |
| 596 | path.to_owned() | |
| 597 | } else { | |
| 598 | format!("{root}/{path}") | |
| 640 | let file = on_share(root, path); | |
| 641 | let key = self.unlocked().get(path).cloned(); | |
| 642 | // A protected section's replica is named by the identity its file holds. | |
| 643 | let cache = match &key { | |
| 644 | Some(_) => self | |
| 645 | .notebook() | |
| 646 | .as_ref() | |
| 647 | .ok_or("The notebook hasn’t been read")? | |
| 648 | .replica_path(path)?, | |
| 649 | None => { | |
| 650 | let identity = self | |
| 651 | .listed() | |
| 652 | .and_then(|mut listed| listed.files.remove(path)) | |
| 653 | .ok_or("The notebook hasn’t listed this section")?; | |
| 654 | let replicas = self.share_replicas().ok_or("Not a share")?; | |
| 655 | replicas.join(format!("{identity}.sqlite")) | |
| 656 | } | |
| 599 | 657 | }; |
| 600 | let identity = self | |
| 601 | .listed() | |
| 602 | .and_then(|mut listed| listed.files.remove(path)) | |
| 603 | .ok_or("The notebook hasn’t listed this section")?; | |
| 604 | let replicas = self.share_replicas().ok_or("Not a share")?; | |
| 605 | let cache = replicas.join(format!("{identity}.sqlite")); | |
| 606 | std::fs::create_dir_all(&replicas)?; | |
| 607 | let replica = Replica::open_or_create(&cache, || { | |
| 658 | std::fs::create_dir_all(cache.parent().ok_or("No cache")?)?; | |
| 659 | let replica = Replica::open_or_create(&cache, key.as_ref(), || { | |
| 608 | 660 | let client = self.client().ok_or_else(|| { |
| 609 | 661 | std::io::Error::new( |
| 610 | 662 | std::io::ErrorKind::NotConnected, |
| ... | ... | @@ -642,13 +694,20 @@ pub(crate) enum Status { |
| 642 | 694 | pub(crate) struct Shared { |
| 643 | 695 | pub section: session::Section, |
| 644 | 696 | /// Who the edits name, as OneNote names the Office user. |
| 645 | pub author: String, | |
| 697 | pub author: Mutex<String>, | |
| 646 | 698 | pub status: AtomicU8, |
| 647 | 699 | } |
| 648 | 700 | |
| 649 | 701 | impl Shared { |
| 702 | pub fn author(&self) -> String { | |
| 703 | self.author | |
| 704 | .lock() | |
| 705 | .unwrap_or_else(|error| error.into_inner()) | |
| 706 | .clone() | |
| 707 | } | |
| 708 | ||
| 650 | 709 | pub fn apply(&self, edit: Edit) -> Result<()> { |
| 651 | self.section.apply(&self.author, edit)?; | |
| 710 | self.section.apply(&self.author(), edit)?; | |
| 652 | 711 | self.status.store(Status::Saving as u8, Ordering::Relaxed); |
| 653 | 712 | Ok(()) |
| 654 | 713 | } |
| ... | ... | @@ -694,7 +753,7 @@ pub(crate) struct Version { |
| 694 | 753 | pub(crate) created: Option<i64>, |
| 695 | 754 | } |
| 696 | 755 | |
| 697 | fn unix(filetime: u64) -> i64 { | |
| 756 | pub(crate) fn unix(filetime: u64) -> i64 { | |
| 698 | 757 | (filetime / 10_000_000) as i64 - 11_644_473_600 |
| 699 | 758 | } |
| 700 | 759 | |
| ... | ... | @@ -709,7 +768,7 @@ pub(crate) fn filetime() -> u64 { |
| 709 | 768 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats |
| 710 | 769 | /// them: the long date and the short time; titled in the Default font's face. |
| 711 | 770 | fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { |
| 712 | let font = crate::options().1; | |
| 771 | let font = crate::options().1.clone(); | |
| 713 | 772 | Ok(PageCreation::new(None, Some(""), author)? |
| 714 | 773 | .titled_in(&font.face, font.color)? |
| 715 | 774 | .dated(date, time)?) |
| ... | ... | @@ -718,12 +777,8 @@ fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { |
| 718 | 777 | impl Section { |
| 719 | 778 | /// The notebook's themes; none for a lone section. |
| 720 | 779 | pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { |
| 721 | let notebook = self | |
| 722 | .library | |
| 723 | .notebook | |
| 724 | .lock() | |
| 725 | .unwrap_or_else(|error| error.into_inner()); | |
| 726 | notebook | |
| 780 | self.library | |
| 781 | .notebook() | |
| 727 | 782 | .as_ref() |
| 728 | 783 | .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) |
| 729 | 784 | .unwrap_or_default() |
| ... | ... | @@ -731,12 +786,8 @@ impl Section { |
| 731 | 786 | |
| 732 | 787 | /// Merges `change` into the notebook's themes. |
| 733 | 788 | pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { |
| 734 | let notebook = self | |
| 735 | .library | |
| 736 | .notebook | |
| 737 | .lock() | |
| 738 | .unwrap_or_else(|error| error.into_inner()); | |
| 739 | notebook | |
| 789 | self.library | |
| 790 | .notebook() | |
| 740 | 791 | .as_ref() |
| 741 | 792 | .ok_or("A section on its own keeps no themes")? |
| 742 | 793 | .save_themes(change)?; |
| ... | ... | @@ -772,7 +823,7 @@ impl Section { |
| 772 | 823 | ) -> Result<Self> { |
| 773 | 824 | let shared = Arc::new(Shared { |
| 774 | 825 | section: library.section(path, notify)?, |
| 775 | author, | |
| 826 | author: Mutex::new(author), | |
| 776 | 827 | status: AtomicU8::new(0), |
| 777 | 828 | }); |
| 778 | 829 | let mut open = library |
| ... | ... | @@ -792,8 +843,7 @@ impl Section { |
| 792 | 843 | .into_iter() |
| 793 | 844 | .map(|(space, title, level)| Row { |
| 794 | 845 | id: space.to_string(), |
| 795 | // Titles keep OneNote's line breaks, which a one-line list shows as spaces. | |
| 796 | title: title.replace(|char: char| char.is_control(), " "), | |
| 846 | title: one_line(&title), | |
| 797 | 847 | level, |
| 798 | 848 | versions: conflicts |
| 799 | 849 | .iter() |
| ... | ... | @@ -821,7 +871,7 @@ impl Section { |
| 821 | 871 | date: &str, |
| 822 | 872 | time: &str, |
| 823 | 873 | ) -> Result<ExGuid> { |
| 824 | let creation = dated(&self.shared.author, date, time)?; | |
| 874 | let creation = dated(&self.shared.author(), date, time)?; | |
| 825 | 875 | let space = creation.space(); |
| 826 | 876 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; |
| 827 | 877 | if let Some(parent) = parent { |
| ... | ... | @@ -857,14 +907,14 @@ impl Section { |
| 857 | 907 | if listed.iter().any(|(page, ..)| *page == space) { |
| 858 | 908 | if self.library.notebook().is_some() { |
| 859 | 909 | let page = section.page(space)?; |
| 860 | let author = &self.shared.author; | |
| 910 | let author = self.shared.author(); | |
| 861 | 911 | self.library.with_notebook(true, |notebook| { |
| 862 | Ok(notebook.recycle_pages(std::slice::from_ref(&page), author)?) | |
| 912 | Ok(notebook.recycle_pages(std::slice::from_ref(&page), &author)?) | |
| 863 | 913 | })?; |
| 864 | 914 | } |
| 865 | 915 | if listed.len() == 1 { |
| 866 | 916 | ops.push(Op::Section(SectionOp::Create(dated( |
| 867 | &self.shared.author, | |
| 917 | &self.shared.author(), | |
| 868 | 918 | date, |
| 869 | 919 | time, |
| 870 | 920 | )?))); |
| ... | ... | @@ -958,7 +1008,7 @@ impl Library { |
| 958 | 1008 | .map(|found| Found { |
| 959 | 1009 | section: found.section, |
| 960 | 1010 | page: found.space.to_string(), |
| 961 | title: found.title.replace(|char: char| char.is_control(), " "), | |
| 1011 | title: one_line(&found.title), | |
| 962 | 1012 | snippet: found.snippet, |
| 963 | 1013 | }) |
| 964 | 1014 | .collect() |
| ... | ... | @@ -975,7 +1025,7 @@ impl Library { |
| 975 | 1025 | .map(|tagged: Tagged| Tag { |
| 976 | 1026 | section: tagged.section, |
| 977 | 1027 | page: tagged.space.to_string(), |
| 978 | title: tagged.title.replace(|char: char| char.is_control(), " "), | |
| 1028 | title: one_line(&tagged.title), | |
| 979 | 1029 | paragraph: tagged.paragraph.to_string(), |
| 980 | 1030 | name: tagged.name, |
| 981 | 1031 | shape: tagged.shape, |
| ... | ... | @@ -1169,11 +1219,7 @@ impl Library { |
| 1169 | 1219 | let (stamp, read): (Vec<u8>, Reader) = match &self.place { |
| 1170 | 1220 | Place::Share { root, .. } => { |
| 1171 | 1221 | let client = self.client().ok_or("Not a share")?; |
| 1172 | let file = if root.is_empty() { | |
| 1173 | path.to_owned() | |
| 1174 | } else { | |
| 1175 | format!("{root}/{path}") | |
| 1176 | }; | |
| 1222 | let file = on_share(root, path); | |
| 1177 | 1223 | let stamp = client.stamp(&file)?; |
| 1178 | 1224 | let mut key = stamp.header.to_vec(); |
| 1179 | 1225 | key.extend(stamp.length.to_le_bytes()); |
| ... | ... | @@ -1240,6 +1286,16 @@ fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T { |
| 1240 | 1286 | } |
| 1241 | 1287 | } |
| 1242 | 1288 | |
| 1289 | fn shared(result: Result<Library>, error: *mut *mut c_char) -> *const Library { | |
| 1290 | match result { | |
| 1291 | Ok(library) => Arc::into_raw(Arc::new(library)), | |
| 1292 | Err(cause) => { | |
| 1293 | failed(cause, error); | |
| 1294 | std::ptr::null() | |
| 1295 | } | |
| 1296 | } | |
| 1297 | } | |
| 1298 | ||
| 1243 | 1299 | pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char { |
| 1244 | 1300 | report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) |
| 1245 | 1301 | .map_or(std::ptr::null_mut(), owned) |
| ... | ... | @@ -1249,10 +1305,6 @@ pub(crate) fn optional(text: *const c_char) -> Option<String> { |
| 1249 | 1305 | (!text.is_null()).then(|| string(text)) |
| 1250 | 1306 | } |
| 1251 | 1307 | |
| 1252 | fn space(text: *const c_char) -> Result<ExGuid> { | |
| 1253 | Ok(string(text).parse()?) | |
| 1254 | } | |
| 1255 | ||
| 1256 | 1308 | /// The notebook folder or lone section file at `path`, its section replicas kept in the |
| 1257 | 1309 | /// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this |
| 1258 | 1310 | /// device has the host report its changes to `sb_library_touched`; any other is checked |
| ... | ... | @@ -1267,13 +1319,10 @@ pub unsafe extern "C" fn sb_library_open( |
| 1267 | 1319 | local: bool, |
| 1268 | 1320 | error: *mut *mut c_char, |
| 1269 | 1321 | ) -> *const Library { |
| 1270 | match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) { | |
| 1271 | Ok(library) => Arc::into_raw(Arc::new(library)), | |
| 1272 | Err(cause) => { | |
| 1273 | failed(cause, error); | |
| 1274 | std::ptr::null() | |
| 1275 | } | |
| 1276 | } | |
| 1322 | shared( | |
| 1323 | Library::open(Path::new(&string(path)), Path::new(&string(cache)), local), | |
| 1324 | error, | |
| 1325 | ) | |
| 1277 | 1326 | } |
| 1278 | 1327 | |
| 1279 | 1328 | /// The notebook folder `root`, `/`-separated from the top of `share` on the server at |
| ... | ... | @@ -1295,13 +1344,10 @@ pub unsafe extern "C" fn sb_library_server( |
| 1295 | 1344 | error: *mut *mut c_char, |
| 1296 | 1345 | ) -> *const Library { |
| 1297 | 1346 | let server = server(address, share, user, password, domain); |
| 1298 | match Library::server(server, &string(root), Path::new(&string(cache))) { | |
| 1299 | Ok(library) => Arc::into_raw(Arc::new(library)), | |
| 1300 | Err(cause) => { | |
| 1301 | failed(cause, error); | |
| 1302 | std::ptr::null() | |
| 1303 | } | |
| 1304 | } | |
| 1347 | shared( | |
| 1348 | Library::server(server, &string(root), Path::new(&string(cache))), | |
| 1349 | error, | |
| 1350 | ) | |
| 1305 | 1351 | } |
| 1306 | 1352 | |
| 1307 | 1353 | /// Creates the notebook folder `path` holding one section with one page titled with `date` |
| ... | ... | @@ -1419,9 +1465,32 @@ pub unsafe extern "C" fn sb_library_tagged( |
| 1419 | 1465 | json(library.tagged(open.map(|section| (path.as_str(), section)))) |
| 1420 | 1466 | } |
| 1421 | 1467 | |
| 1422 | /// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to | |
| 1423 | /// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced` | |
| 1424 | /// (seconds since 1970, or null before the file was reached), `queued` changes and `error`. | |
| 1468 | /// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's | |
| 1469 | /// Protected Section dialog does; false for a wrong password. The key stays in memory until | |
| 1470 | /// `sb_library_lock_all`. | |
| 1471 | /// | |
| 1472 | /// # Safety | |
| 1473 | /// `path` and `password` are NUL-terminated UTF-8. | |
| 1474 | #[unsafe(no_mangle)] | |
| 1475 | pub unsafe extern "C" fn sb_library_unlock( | |
| 1476 | library: &Library, | |
| 1477 | path: *const c_char, | |
| 1478 | password: *const c_char, | |
| 1479 | ) -> bool { | |
| 1480 | let password = zeroize::Zeroizing::new(string(password)); | |
| 1481 | report(library.unlock(&string(path), &password)).unwrap_or(false) | |
| 1482 | } | |
| 1483 | ||
| 1484 | /// Locks every protected section of the notebook, forgetting their keys: they open again only | |
| 1485 | /// once unlocked. | |
| 1486 | #[unsafe(no_mangle)] | |
| 1487 | pub extern "C" fn sb_library_lock_all(library: &Library) { | |
| 1488 | library.unlocked().clear(); | |
| 1489 | } | |
| 1490 | ||
| 1491 | /// Each section's sync status as JSON, in the notebook's order: `path`, `state` (`SyncState` | |
| 1492 | /// by its place, best first), `synced` (seconds since 1970, or null before the file was | |
| 1493 | /// reached), `queued` changes and `error`. | |
| 1425 | 1494 | #[unsafe(no_mangle)] |
| 1426 | 1495 | pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { |
| 1427 | 1496 | json(Ok(library.sync_status())) |
| ... | ... | @@ -1735,8 +1804,11 @@ pub unsafe extern "C" fn sb_section_delete_page( |
| 1735 | 1804 | date: *const c_char, |
| 1736 | 1805 | time: *const c_char, |
| 1737 | 1806 | ) -> bool { |
| 1738 | report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time)))) | |
| 1739 | .is_some() | |
| 1807 | let deleted = string(id) | |
| 1808 | .parse() | |
| 1809 | .map_err(Into::into) | |
| 1810 | .and_then(|space| section.delete_page(space, &string(date), &string(time))); | |
| 1811 | report(deleted).is_some() | |
| 1740 | 1812 | } |
| 1741 | 1813 | |
| 1742 | 1814 | /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be |
| ... | ... | @@ -1746,6 +1818,19 @@ pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool { |
| 1746 | 1818 | section.flush(Duration::from_secs_f64(seconds)) |
| 1747 | 1819 | } |
| 1748 | 1820 | |
| 1821 | /// Names `author` in the section's edits from now on, as when the reader changes their name. | |
| 1822 | /// | |
| 1823 | /// # Safety | |
| 1824 | /// `author` is NUL-terminated UTF-8. | |
| 1825 | #[unsafe(no_mangle)] | |
| 1826 | pub unsafe extern "C" fn sb_section_set_author(section: &Section, author: *const c_char) { | |
| 1827 | *section | |
| 1828 | .shared | |
| 1829 | .author | |
| 1830 | .lock() | |
| 1831 | .unwrap_or_else(|error| error.into_inner()) = string(author); | |
| 1832 | } | |
| 1833 | ||
| 1749 | 1834 | /// Asks the section to look for changes made elsewhere now, as when the app returns. |
| 1750 | 1835 | #[unsafe(no_mangle)] |
| 1751 | 1836 | pub extern "C" fn sb_section_wake(section: &Section) { |
crates/mobile/src/tests.rs+25-1| ... | ... | @@ -697,7 +697,31 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { |
| 697 | 697 | section.shared.apply(edit).unwrap(); |
| 698 | 698 | let stored = section.shared.page(canvas.space).unwrap().0.date_text(); |
| 699 | 699 | assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026"); |
| 700 | assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); | |
| 700 | let after = canvas.page.editor.date().unwrap().timestamp(); | |
| 701 | assert_ne!(after, before); | |
| 702 | // `sb_view_date_request` hands the host the date in its seconds. | |
| 703 | assert_eq!(library::unix(after), 1_767_349_800); | |
| 704 | } | |
| 705 | ||
| 706 | #[test] | |
| 707 | fn pages_open_with_the_snap_and_default_font_options_set() { | |
| 708 | // OneNote's defaults, so pages other tests open meanwhile see no change. | |
| 709 | sb_set_snap_to_grid(true); | |
| 710 | unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) }; | |
| 711 | assert_eq!(*options(), (true, canvas::editor::DefaultFont::default())); | |
| 712 | let (_directory, section) = features(); | |
| 713 | let canvas = canvas(&section, "Paragraph controls"); | |
| 714 | assert!(canvas.page.snap_to_grid); | |
| 715 | assert_eq!(canvas.page.editor.default_font.face, "Calibri"); | |
| 716 | } | |
| 717 | ||
| 718 | #[test] | |
| 719 | fn a_share_notebook_names_its_files_from_the_share_s_top() { | |
| 720 | assert_eq!(library::on_share("", "Notes.one"), "Notes.one"); | |
| 721 | assert_eq!( | |
| 722 | library::on_share("Team/Book", "Group/Notes.one"), | |
| 723 | "Team/Book/Group/Notes.one" | |
| 724 | ); | |
| 701 | 725 | } |
| 702 | 726 | |
| 703 | 727 | /// A notebook copy with its library, and a section of it open for editing. |
crates/notebook/Cargo.toml+8-2| ... | ... | @@ -6,7 +6,6 @@ publish = false |
| 6 | 6 | |
| 7 | 7 | [features] |
| 8 | 8 | smb = ["dep:smb2", "dep:tokio"] |
| 9 | protected = ["onestore/protected", "dep:zeroize"] | |
| 10 | 9 | |
| 11 | 10 | [dependencies] |
| 12 | 11 | onestore = { path = "../onestore" } |
| ... | ... | @@ -18,7 +17,14 @@ tempfile = "3" |
| 18 | 17 | sha2 = "0.11" |
| 19 | 18 | smb2 = { version = "=0.21.0", optional = true } |
| 20 | 19 | tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } |
| 21 | zeroize = { version = "1.9.0", optional = true } | |
| 20 | zeroize = "1.9.0" | |
| 21 | # Queued edits of a password-protected section are sealed under a key derived from its own. | |
| 22 | aes-gcm = "0.11.1" | |
| 23 | base64 = "0.22.1" | |
| 24 | getrandom = "0.4.3" | |
| 25 | hmac = "0.13.0" | |
| 26 | # OneNote packages (.onepkg) are cabinet files. | |
| 27 | cab = "0.6" | |
| 22 | 28 | |
| 23 | 29 | [target.'cfg(target_vendor = "apple")'.dependencies] |
| 24 | 30 | nix = { version = "0.31", default-features = false, features = ["fs"] } |
crates/notebook/README.md+52-13| ... | ... | @@ -196,7 +196,9 @@ or worker panic. Dropping it requests cancellation without waiting. Remote |
| 196 | 196 | operations and callbacks must have bounded execution times if shutdown latency |
| 197 | 197 | matters. Credentials belong to the factory, not the cache database. |
| 198 | 198 | |
| 199 | Creation refuses existing paths. Opening validates database integrity and replays | |
| 199 | `create` refuses an existing path; `open_or_create(path, key, source)` opens the cache at | |
| 200 | `path`, reading `source` only to make it where there is none, and opens one another thread | |
| 201 | made meanwhile. Opening validates database integrity and replays | |
| 200 | 202 | the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL |
| 201 | 203 | synchronization and fullfsync, each queried back: every commit is durable, and the only |
| 202 | 204 | file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint |
| ... | ... | @@ -324,7 +326,9 @@ identities, titles, dates and creation times, as OneNote does |
| 324 | 326 | (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as |
| 325 | 327 | OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is |
| 326 | 328 | placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an |
| 327 | unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still | |
| 329 | unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle_bin` is OneNote's | |
| 330 | Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's | |
| 331 | file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still | |
| 328 | 332 | lists for a file gone from its folder gives way to the file an edit gives its name. |
| 329 | 333 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps |
| 330 | 334 | the numbers. Every created, renamed or moved file is placed with |
| ... | ... | @@ -346,13 +350,34 @@ targets are `None`. |
| 346 | 350 | replica, and `session::stored_pages(image)` builds its pages with each page's |
| 347 | 351 | `LastModifiedTime`: the app's search indexes the sections it has not opened this way. |
| 348 | 352 | |
| 349 | With the optional `protected` feature, `Notebook::unlock(path, password)` | |
| 350 | reads a `Locked` section as `Unlocked { pages, .. }`, and | |
| 351 | `Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)` | |
| 352 | applies page ops and stores them under the section's key, straight to the file: | |
| 353 | nothing of a protected section is cached or queued, a section written since | |
| 354 | `unlocked` was read refuses the edit, and a wrong password is | |
| 355 | `Error::Protected(PasswordMismatch)`. | |
| 353 | A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens | |
| 354 | its key (a wrong password is `Error::Protected(PasswordMismatch)`), and | |
| 355 | `section_unlocked(path, &key, notify)` (`section_unlocked_with`, or | |
| 356 | `Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself) | |
| 357 | opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do, | |
| 358 | each revision sealed under the section's key. The replica keeps none of it in the clear: | |
| 359 | its base and transactions are the file's own ciphertext, and its queued edits and payloads | |
| 360 | are sealed with AES-256-GCM (a random nonce each, the row's kind as associated data) under | |
| 361 | a key HMAC-SHA256 derives from the section's, payloads named by HMAC rather than SHA-256. | |
| 362 | Only the author's name, payload names and revision identities stay readable, and the | |
| 363 | queue opens only under the key (a recovery archive with `Recovery::open_unlocked`). | |
| 364 | `stored_pages_unlocked(image, &key)` reads a protected section's pages for search. The | |
| 365 | background never opens a locked section. | |
| 366 | ||
| 367 | `Notebook::set_password(path, key, password)` sets, changes or removes a password as | |
| 368 | OneNote 2010 does: the section is written anew under new identities | |
| 369 | (`onestore::protected::rekey`), next to the file as a dot file that `Storage::supersede` | |
| 370 | puts in its place under the writers' coordination while the file's stamp holds (on a share as | |
| 371 | OneNote's maintenance does it: the old file aside, the new one in, the old one deleted), and | |
| 372 | its TOC entry takes the new identity (`TocEdit::Reidentify`). The old replica, a cache of the | |
| 373 | old file, is deleted, so the section's session must be closed and its edits published first; | |
| 374 | a section whose edits wait refuses. | |
| 375 | ||
| 376 | Another device learns of the password when it reads the notebook again (`open`, `refresh`): | |
| 377 | a replica of the file the protected section superseded, found by the placement the rewritten | |
| 378 | header keeps, is deleted when nothing waits in it. Edits that wait stay until `unlock` on that | |
| 379 | device queues them under the key, each page they changed coming back as a copy, as a page | |
| 380 | another client removed does. A session open meanwhile stops as `SyncState::Protected`. | |
| 356 | 381 | |
| 357 | 382 | `Section::import_page(page, author)` copies a page, usually read from another |
| 358 | 383 | section, to the end of this one as one `SectionOp::Import` queued like the |
| ... | ... | @@ -369,12 +394,27 @@ is `Reordered`. A failed read returns the error and keeps |
| 369 | 394 | the previous catalog, so an unreachable share never reads as an emptied |
| 370 | 395 | notebook. |
| 371 | 396 | |
| 397 | ## Packages | |
| 398 | ||
| 399 | `package` reads and writes OneNote packages (`.onepkg`), cabinet files of a notebook's tables | |
| 400 | of contents and sections at their paths in its folder. `notebook_files(notebook, image)` gathers | |
| 401 | what OneNote 2010's Save As packs (each folder's TOC and sections, groups after, the recycle bin | |
| 402 | and copies left out, its TOC entry kept), taking a section the caller holds edits for from | |
| 403 | `image` (a replica's `snapshot`); `pack` writes them MSZIP-compressed, each file outside any | |
| 404 | notebook (no `guidAncestor` or `crcName`), as OneNote's are. `read` takes MSZIP or OneNote's LZX, | |
| 405 | refusing a path leaving the folder or more than a byte limit unpacked, and `unpack(files, root)` | |
| 406 | writes a new folder as Unpack Notebook does: every file takes an identity of its own | |
| 407 | (`onestore::reidentify`) and is placed under its folder's TOC, whose entries follow it where | |
| 408 | OneNote lists each anew beside the stale one. `section_copy` and `page_section` are Save As's | |
| 409 | section and page: a copy under a new identity, and a new section holding the page with its | |
| 410 | identity, both outside any notebook (`corpus/notebook-package`). | |
| 411 | ||
| 372 | 412 | ## Snowbound's folder |
| 373 | 413 | |
| 374 | 414 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which |
| 375 | 415 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it |
| 376 | through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a | |
| 377 | dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's | |
| 416 | on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden | |
| 417 | without storing it), `SetFileAttributesW` on Windows, or macOS's | |
| 378 | 418 | `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook |
| 379 | 419 | without the folder is whole. |
| 380 | 420 | |
| ... | ... | @@ -475,8 +515,7 @@ probing the connection, so an idle watch sends nothing. `changed` hears each bat |
| 475 | 515 | paths relative to the directory, `""` when the server lost count, and an error when the watch |
| 476 | 516 | ends with its connection. |
| 477 | 517 | |
| 478 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | |
| 479 | share handle that excludes writes and deletion. Empty files succeed; limits, | |
| 518 | An external payload on a share (`discover::Smb`) is read under a read-only share handle that excludes writes and deletion. Empty files succeed; limits, | |
| 480 | 519 | interrupted reads and failed close never return partial bytes. This payload read |
| 481 | 520 | does not parse a OneStore header or acquire its reader-coordination bytes. |
| 482 | 521 |
crates/notebook/examples/document.rs-11| ... | ... | @@ -39,7 +39,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 39 | 39 | let bytes = onestore::read_file(&source_path)?; |
| 40 | 40 | let store = Store::parse(&bytes)?; |
| 41 | 41 | let index = RevisionIndex::parse(&store)?; |
| 42 | #[cfg(feature = "protected")] | |
| 43 | 42 | let unlocked = if let Some(path) = &password_file { |
| 44 | 43 | use std::io::Read; |
| 45 | 44 | let mut password = zeroize::Zeroizing::new(String::new()); |
| ... | ... | @@ -57,20 +56,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 57 | 56 | } else { |
| 58 | 57 | None |
| 59 | 58 | }; |
| 60 | #[cfg(not(feature = "protected"))] | |
| 61 | if password_file.is_some() { | |
| 62 | return Err( | |
| 63 | "Build this exporter with the protected feature to open a password-protected section." | |
| 64 | .into(), | |
| 65 | ); | |
| 66 | } | |
| 67 | #[cfg(feature = "protected")] | |
| 68 | 59 | let document = match &unlocked { |
| 69 | 60 | Some(section) => section.document()?, |
| 70 | 61 | None => Document::parse(&index)?, |
| 71 | 62 | }; |
| 72 | #[cfg(not(feature = "protected"))] | |
| 73 | let document = Document::parse(&index)?; | |
| 74 | 63 | if let Some(destination) = destination { |
| 75 | 64 | #[cfg(unix)] |
| 76 | 65 | { |
crates/notebook/examples/keystroke_probe.rs+1| ... | ... | @@ -135,6 +135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 135 | 135 | identity: None, |
| 136 | 136 | created: None, |
| 137 | 137 | margin_origin: [0.0; 2], |
| 138 | rtl: false, | |
| 138 | 139 | color: None, |
| 139 | 140 | rule_lines: None, |
| 140 | 141 | objects: vec![PageObject::Outline(Outline { |
crates/notebook/examples/open_probe.rs+1| ... | ... | @@ -44,6 +44,7 @@ fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> { |
| 44 | 44 | identity: None, |
| 45 | 45 | created: None, |
| 46 | 46 | margin_origin: [0.0; 2], |
| 47 | rtl: false, | |
| 47 | 48 | color: None, |
| 48 | 49 | rule_lines: None, |
| 49 | 50 | objects: vec![PageObject::Outline(Outline { |
crates/notebook/examples/save_probe.rs+1| ... | ... | @@ -50,6 +50,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 50 | 50 | identity: None, |
| 51 | 51 | created: None, |
| 52 | 52 | margin_origin: [0.0; 2], |
| 53 | rtl: false, | |
| 53 | 54 | color: None, |
| 54 | 55 | rule_lines: None, |
| 55 | 56 | objects: vec![PageObject::Outline(Outline { |
crates/notebook/src/background.rs+11-6| ... | ... | @@ -30,8 +30,6 @@ const STAGGER: Duration = Duration::from_millis(100); |
| 30 | 30 | /// How long a reported change settles before its section is checked, so the writes of one |
| 31 | 31 | /// commit cost one check. |
| 32 | 32 | const SETTLE: Duration = Duration::from_secs(1); |
| 33 | /// The most entries a folder listing takes. | |
| 34 | const ENTRIES: usize = 100_000; | |
| 35 | 33 | |
| 36 | 34 | /// Keeps each watched section in sync: a section is checked when a watch on the notebook's |
| 37 | 35 | /// folder reports it changed, when it could not be reached, and otherwise every interval. |
| ... | ... | @@ -66,6 +64,7 @@ pub(crate) struct Shared { |
| 66 | 64 | } |
| 67 | 65 | |
| 68 | 66 | /// A connection's report of its notebook folder's changes, from a watch armed on connecting. |
| 67 | #[cfg_attr(not(feature = "smb"), allow(dead_code))] | |
| 69 | 68 | pub(crate) struct Reports { |
| 70 | 69 | shared: Weak<Shared>, |
| 71 | 70 | connection: u64, |
| ... | ... | @@ -338,7 +337,8 @@ impl Background { |
| 338 | 337 | let root = root.clone(); |
| 339 | 338 | let list = move |folder: &str| { |
| 340 | 339 | use crate::discover::Source; |
| 341 | crate::discover::Smb::new(&client, &root)?.entries(folder, ENTRIES) | |
| 340 | crate::discover::Smb::new(&client, &root)? | |
| 341 | .entries(folder, crate::session::LIMITS.entries) | |
| 342 | 342 | }; |
| 343 | 343 | Ok(((bind, list), reported)) |
| 344 | 344 | }, |
| ... | ... | @@ -361,6 +361,7 @@ impl Background { |
| 361 | 361 | pub fn hold(&self, path: &str, section: &Section) { |
| 362 | 362 | let replica = section.replica(); |
| 363 | 363 | let Some(worker) = replica |
| 364 | .section | |
| 364 | 365 | .worker |
| 365 | 366 | .lock() |
| 366 | 367 | .ok() |
| ... | ... | @@ -487,6 +488,7 @@ impl Shared { |
| 487 | 488 | } |
| 488 | 489 | } |
| 489 | 490 | |
| 491 | #[cfg_attr(not(feature = "smb"), allow(dead_code))] | |
| 490 | 492 | impl Reports { |
| 491 | 493 | /// The sections at or below these paths changed. |
| 492 | 494 | pub(crate) fn touched(&self, paths: &[String]) { |
| ... | ... | @@ -741,7 +743,10 @@ fn list( |
| 741 | 743 | |
| 742 | 744 | /// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it. |
| 743 | 745 | fn discard(replica: &Path) { |
| 744 | if matches!(crate::peek(replica), Ok((_, 0))) { | |
| 746 | if matches!( | |
| 747 | crate::closed(replica).and_then(|held| crate::peek(&held)), | |
| 748 | Ok((_, 0)) | |
| 749 | ) { | |
| 745 | 750 | for suffix in ["-wal", "-shm", ""] { |
| 746 | 751 | let mut file = replica.as_os_str().to_owned(); |
| 747 | 752 | file.push(suffix); |
| ... | ... | @@ -837,7 +842,7 @@ fn step<R: Remote>( |
| 837 | 842 | if let Some(folder) = replica.parent() { |
| 838 | 843 | std::fs::create_dir_all(folder)?; |
| 839 | 844 | } |
| 840 | Replica::seed(replica, &image)?; | |
| 845 | Replica::seed(replica, &image, None)?; | |
| 841 | 846 | Ok(Stamp::of(&image)?) |
| 842 | 847 | })(); |
| 843 | 848 | return match copied { |
| ... | ... | @@ -854,7 +859,7 @@ fn step<R: Remote>( |
| 854 | 859 | Ok(versions) => versions, |
| 855 | 860 | Err(error) => return (None, Err(Error::RemoteIo(error))), |
| 856 | 861 | }; |
| 857 | match crate::peek(replica) { | |
| 862 | match crate::closed(replica).and_then(|held| crate::peek(&held)) { | |
| 858 | 863 | Ok((base, 0)) if base == stamp && versions.is_empty() => { |
| 859 | 864 | return (Some(0), Ok((stamp, moved))); |
| 860 | 865 | } |
crates/notebook/src/base.rs+14| ... | ... | @@ -28,6 +28,20 @@ fn damaged() -> crate::Error { |
| 28 | 28 | io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image").into() |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | /// The base image, which every cache holds. | |
| 32 | pub(crate) fn base(connection: &Connection) -> Result<Vec<u8>> { | |
| 33 | read(connection, Image::Base)?.ok_or_else(missing) | |
| 34 | } | |
| 35 | ||
| 36 | /// The base image's stamp. | |
| 37 | pub(crate) fn base_stamp(connection: &Connection) -> Result<Stamp> { | |
| 38 | stamp(connection, Image::Base)?.ok_or_else(missing) | |
| 39 | } | |
| 40 | ||
| 41 | fn missing() -> crate::Error { | |
| 42 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image").into() | |
| 43 | } | |
| 44 | ||
| 31 | 45 | /// The whole image, or `None` when none is stored. |
| 32 | 46 | pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> { |
| 33 | 47 | let mut query = connection.prepare_cached(&format!( |
crates/notebook/src/discover.rs+40-19| ... | ... | @@ -50,10 +50,30 @@ pub struct Folder { |
| 50 | 50 | pub toc: Option<Toc>, |
| 51 | 51 | pub sections: Vec<Section>, |
| 52 | 52 | pub groups: Vec<Folder>, |
| 53 | /// The paths of `sections` and `groups` together, in the order the TOC lists them, those | |
| 54 | /// it doesn't last by path. | |
| 55 | pub order: Vec<String>, | |
| 53 | 56 | /// Child section files and groups that could not be read. |
| 54 | 57 | pub unavailable: Vec<Unavailable>, |
| 55 | 58 | } |
| 56 | 59 | |
| 60 | impl Folder { | |
| 61 | /// This folder and every group under it, in catalog order. | |
| 62 | pub(crate) fn folders(&self) -> impl Iterator<Item = &Folder> { | |
| 63 | let mut stack = vec![self]; | |
| 64 | std::iter::from_fn(move || { | |
| 65 | let folder = stack.pop()?; | |
| 66 | stack.extend(folder.groups.iter().rev()); | |
| 67 | Some(folder) | |
| 68 | }) | |
| 69 | } | |
| 70 | ||
| 71 | /// The sections of this folder and every group under it, in catalog order. | |
| 72 | pub(crate) fn sections(&self) -> impl Iterator<Item = &Section> { | |
| 73 | self.folders().flat_map(|folder| &folder.sections) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 57 | 77 | /// A section file or group folder denied or gone while listing; each discovery tries it again. |
| 58 | 78 | #[derive(Debug, Serialize)] |
| 59 | 79 | pub struct Unavailable { |
| ... | ... | @@ -346,6 +366,7 @@ fn scan( |
| 346 | 366 | toc: None, |
| 347 | 367 | sections: Vec::new(), |
| 348 | 368 | groups: Vec::new(), |
| 369 | order: Vec::new(), | |
| 349 | 370 | unavailable: Vec::new(), |
| 350 | 371 | }; |
| 351 | 372 | for entry in &listing { |
| ... | ... | @@ -551,26 +572,26 @@ fn scan( |
| 551 | 572 | .flat_map(|toc| &toc.unresolved) |
| 552 | 573 | .map(|entry| (entry.file, entry.order)) |
| 553 | 574 | .collect(); |
| 554 | result.sections.sort_by(|a, b| { | |
| 555 | (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path) | |
| 556 | .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path)) | |
| 557 | }); | |
| 558 | result.groups.sort_by(|a, b| { | |
| 559 | ( | |
| 560 | a.toc | |
| 561 | .as_ref() | |
| 562 | .and_then(|toc| order.get(&toc.file_id).copied()) | |
| 563 | .unwrap_or(u32::MAX), | |
| 564 | &a.path, | |
| 575 | let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied()); | |
| 576 | let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone()); | |
| 577 | result | |
| 578 | .sections | |
| 579 | .sort_by_cached_key(|section| ranked(Some(section.file_id), &section.path)); | |
| 580 | let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id); | |
| 581 | result | |
| 582 | .groups | |
| 583 | .sort_by_cached_key(|group| ranked(group_file(group), &group.path)); | |
| 584 | let mut entries: Vec<(u32, String)> = (result.sections.iter()) | |
| 585 | .map(|section| ranked(Some(section.file_id), &section.path)) | |
| 586 | .chain( | |
| 587 | result | |
| 588 | .groups | |
| 589 | .iter() | |
| 590 | .map(|group| ranked(group_file(group), &group.path)), | |
| 565 | 591 | ) |
| 566 | .cmp(&( | |
| 567 | b.toc | |
| 568 | .as_ref() | |
| 569 | .and_then(|toc| order.get(&toc.file_id).copied()) | |
| 570 | .unwrap_or(u32::MAX), | |
| 571 | &b.path, | |
| 572 | )) | |
| 573 | }); | |
| 592 | .collect(); | |
| 593 | entries.sort(); | |
| 594 | result.order = entries.into_iter().map(|(_, path)| path).collect(); | |
| 574 | 595 | let listed = |file: [u8; 16], path: &str| { |
| 575 | 596 | let name = path.rsplit('/').next().unwrap_or(path); |
| 576 | 597 | result |
crates/notebook/src/discover/source.rs+5-1| ... | ... | @@ -194,6 +194,10 @@ impl Source for Smb<'_> { |
| 194 | 194 | return None; |
| 195 | 195 | } |
| 196 | 196 | let stamp = self.client.stamp(&self.path(path).ok()?).ok()?; |
| 197 | crate::copied(&replica, &stamp).ok().flatten() | |
| 197 | let connection = crate::closed(&replica).ok()?; | |
| 198 | let base = crate::base::Image::Base; | |
| 199 | (crate::base::stamp(&connection, base).ok()?? == stamp) | |
| 200 | .then(|| crate::base::read(&connection, base).ok()?) | |
| 201 | .flatten() | |
| 198 | 202 | } |
| 199 | 203 | } |
crates/notebook/src/lib.rs+71-45| ... | ... | @@ -10,6 +10,7 @@ use onestore::{ |
| 10 | 10 | ExGuid, |
| 11 | 11 | op::{Edit, OpError}, |
| 12 | 12 | page::Page, |
| 13 | protected::Key, | |
| 13 | 14 | }; |
| 14 | 15 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; |
| 15 | 16 | use std::{ |
| ... | ... | @@ -26,6 +27,7 @@ mod base; |
| 26 | 27 | pub mod location; |
| 27 | 28 | mod merge; |
| 28 | 29 | mod migrate; |
| 30 | pub mod package; | |
| 29 | 31 | mod queue; |
| 30 | 32 | mod recovery; |
| 31 | 33 | mod resolve; |
| ... | ... | @@ -61,7 +63,7 @@ pub enum Error { |
| 61 | 63 | Rejected(#[from] OpError), |
| 62 | 64 | #[error("External payload identity now refers to different bytes")] |
| 63 | 65 | AssetChanged, |
| 64 | #[cfg(feature = "protected")] | |
| 66 | /// A password-protected section's password did not match, or its format is unknown. | |
| 65 | 67 | #[error(transparent)] |
| 66 | 68 | Protected(#[from] onestore::protected::Error), |
| 67 | 69 | } |
| ... | ... | @@ -98,10 +100,8 @@ pub enum Resolution { |
| 98 | 100 | /// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's |
| 99 | 101 | /// exclusive connection retains ownership between local transactions. |
| 100 | 102 | pub struct Replica { |
| 101 | connection: Arc<Mutex<Connection>>, | |
| 102 | 103 | section: Arc<working::Thread>, |
| 103 | 104 | synchronization: Mutex<()>, |
| 104 | worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>, | |
| 105 | 105 | /// The section's root object space, which names the document. |
| 106 | 106 | root: ExGuid, |
| 107 | 107 | /// Last, so that it runs once the fields above have closed the cache. |
| ... | ... | @@ -124,30 +124,31 @@ impl Replica { |
| 124 | 124 | /// Seeds a new cache from a validated section image, refusing any existing path. |
| 125 | 125 | /// An initialization error preserves the created file for inspection. |
| 126 | 126 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { |
| 127 | Self::seed(path.as_ref(), source)?; | |
| 128 | Self::start(cache_connection(path.as_ref())?) | |
| 127 | Self::seed(path.as_ref(), source, None)?; | |
| 128 | Self::start(cache_connection(path.as_ref())?, None) | |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | /// Opens the cache at `path`, first creating it from the image `source` reads where there | |
| 132 | /// is none. A cache another thread creates meanwhile, as the background makes an offline | |
| 133 | /// copy, is opened instead. | |
| 131 | /// Opens the cache at `path`, a protected section's under `key`, first creating it from | |
| 132 | /// the image `source` reads where there is none. A cache another thread creates meanwhile, | |
| 133 | /// as the background makes an offline copy, is opened instead. | |
| 134 | 134 | pub fn open_or_create( |
| 135 | 135 | path: impl AsRef<Path>, |
| 136 | key: Option<&Key>, | |
| 136 | 137 | source: impl FnOnce() -> Result<Vec<u8>>, |
| 137 | 138 | ) -> Result<Self> { |
| 138 | 139 | let path = path.as_ref(); |
| 139 | 140 | if !path.exists() { |
| 140 | match Self::seed(path, &source()?) { | |
| 141 | match Self::seed(path, &source()?, key) { | |
| 141 | 142 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} |
| 142 | 143 | seeded => seeded?, |
| 143 | 144 | } |
| 144 | 145 | } |
| 145 | Self::open(path) | |
| 146 | Self::open_with(path, key.cloned()) | |
| 146 | 147 | } |
| 147 | 148 | |
| 148 | 149 | /// `create` without opening the cache it made, as for an offline copy. |
| 149 | pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> { | |
| 150 | validate(source)?; | |
| 150 | pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> { | |
| 151 | validate(source, key)?; | |
| 151 | 152 | // Built under a name of its own and linked into place whole, so that a cache that |
| 152 | 153 | // exists is complete, and two threads making the same one never share a build. |
| 153 | 154 | static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| ... | ... | @@ -201,7 +202,10 @@ impl Replica { |
| 201 | 202 | /// a conversion that cannot reproduce every queued page leaves it untouched. |
| 202 | 203 | /// Unrecognized databases and unsupported journal modes are rejected. |
| 203 | 204 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { |
| 204 | let path = path.as_ref(); | |
| 205 | Self::open_with(path.as_ref(), None) | |
| 206 | } | |
| 207 | ||
| 208 | fn open_with(path: &Path, key: Option<Key>) -> Result<Self> { | |
| 205 | 209 | let mut connection = cache_connection(path)?; |
| 206 | 210 | let application: u32 = |
| 207 | 211 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; |
| ... | ... | @@ -233,25 +237,21 @@ impl Replica { |
| 233 | 237 | } |
| 234 | 238 | } |
| 235 | 239 | write_ahead(&connection)?; |
| 236 | Self::start(connection) | |
| 240 | Self::start(connection, key) | |
| 237 | 241 | } |
| 238 | 242 | |
| 239 | fn start(connection: Connection) -> Result<Self> { | |
| 240 | let connection = Arc::new(Mutex::new(connection)); | |
| 241 | let worker = Arc::new(Mutex::new(std::sync::Weak::new())); | |
| 242 | let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; | |
| 243 | fn start(connection: Connection, key: Option<Key>) -> Result<Self> { | |
| 244 | let (section, root) = working::spawn(connection, key)?; | |
| 243 | 245 | Ok(Self { |
| 244 | connection, | |
| 245 | 246 | section, |
| 246 | 247 | synchronization: Mutex::new(()), |
| 247 | worker, | |
| 248 | 248 | root, |
| 249 | 249 | released: Released::default(), |
| 250 | 250 | }) |
| 251 | 251 | } |
| 252 | 252 | |
| 253 | 253 | fn lock(&self) -> Result<MutexGuard<'_, Connection>> { |
| 254 | lock(&self.connection) | |
| 254 | lock(&self.section.connection) | |
| 255 | 255 | } |
| 256 | 256 | |
| 257 | 257 | /// Hands `request` to the section thread. |
| ... | ... | @@ -329,9 +329,9 @@ impl Replica { |
| 329 | 329 | |
| 330 | 330 | /// The section image the queued edits leave, the unsealed ones sealed as one more |
| 331 | 331 | /// revision whose identities differ per call: O(section). |
| 332 | pub(crate) fn snapshot(&self) -> Result<Vec<u8>> { | |
| 332 | pub fn snapshot(&self) -> Result<Vec<u8>> { | |
| 333 | 333 | self.written()?; |
| 334 | working::image(&*self.lock()?) | |
| 334 | working::image(&*self.lock()?, self.section.key.as_ref()) | |
| 335 | 335 | } |
| 336 | 336 | |
| 337 | 337 | /// Waits until the edits applied before it are written to the queue, as reads answer |
| ... | ... | @@ -342,18 +342,51 @@ impl Replica { |
| 342 | 342 | |
| 343 | 343 | /// The section file's identity, which internal links name as `section-id`. |
| 344 | 344 | pub fn identity(&self) -> Result<[u8; 16]> { |
| 345 | let stamp = base::stamp(&*self.lock()?, base::Image::Base)? | |
| 346 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?; | |
| 345 | let stamp = base::base_stamp(&*self.lock()?)?; | |
| 347 | 346 | Ok(onestore::Header::parse(&stamp.header)?.file_id) |
| 348 | 347 | } |
| 349 | 348 | |
| 350 | 349 | /// Queued edits, oldest first. |
| 351 | 350 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { |
| 352 | pending(&*self.lock()?) | |
| 351 | pending(&*self.lock()?, self.section.key.as_ref()) | |
| 352 | } | |
| 353 | ||
| 354 | /// The edits putting each page the queue changed, as the queue leaves it, into another | |
| 355 | /// section as a copy under fresh identities, by the author of the first edit changing it; | |
| 356 | /// content outside the page model stays behind. | |
| 357 | pub(crate) fn copies(&self) -> Result<Vec<(String, Edit)>> { | |
| 358 | let arena = onestore::Arena::default(); | |
| 359 | let base = base::base(&*self.lock()?)?; | |
| 360 | let base = working::open(&arena, base, self.section.key.as_ref())?; | |
| 361 | let mut seen = std::collections::BTreeSet::from([self.root]); | |
| 362 | let mut copies = Vec::new(); | |
| 363 | for queued in self.pending()? { | |
| 364 | for space in queue::spaces(&queued.edit, self.root) { | |
| 365 | if !seen.insert(space) { | |
| 366 | continue; | |
| 367 | } | |
| 368 | let Ok(mut page) = self.page(space) else { | |
| 369 | continue; | |
| 370 | }; | |
| 371 | if base.page(space).is_ok_and(|base| base == page) { | |
| 372 | continue; | |
| 373 | } | |
| 374 | page.objects | |
| 375 | .retain(|object| !matches!(object, onestore::page::PageObject::Unsupported(_))); | |
| 376 | let creation = | |
| 377 | onestore::PageCreation::new(None, Some(&page.title), &queued.author)?; | |
| 378 | let ops = vec![onestore::op::Op::Section(onestore::op::SectionOp::Import { | |
| 379 | creation, | |
| 380 | page: page.copy()?, | |
| 381 | })]; | |
| 382 | copies.push((queued.author.clone(), Edit { at: now(), ops })); | |
| 383 | } | |
| 384 | } | |
| 385 | Ok(copies) | |
| 353 | 386 | } |
| 354 | 387 | |
| 355 | 388 | fn wake_sync(&self) { |
| 356 | wake(&self.worker); | |
| 389 | wake(&self.section.worker); | |
| 357 | 390 | } |
| 358 | 391 | } |
| 359 | 392 | |
| ... | ... | @@ -386,10 +419,14 @@ fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) { |
| 386 | 419 | } |
| 387 | 420 | } |
| 388 | 421 | |
| 389 | /// Fully validates a section image, returning its root object space. | |
| 390 | fn validate(source: &[u8]) -> Result<ExGuid> { | |
| 422 | /// Fully validates a section image, a protected one under `key`, returning its root object | |
| 423 | /// space. | |
| 424 | fn validate(source: &[u8], key: Option<&Key>) -> Result<ExGuid> { | |
| 391 | 425 | let arena = onestore::Arena::default(); |
| 392 | Ok(onestore::Section::open(&arena, source.to_vec())?.root()) | |
| 426 | Ok(match key { | |
| 427 | Some(key) => onestore::Section::unlock(&arena, source.to_vec(), key)?.root(), | |
| 428 | None => onestore::Section::open(&arena, source.to_vec())?.root(), | |
| 429 | }) | |
| 393 | 430 | } |
| 394 | 431 | |
| 395 | 432 | /// A closed cache, without opening the replica. |
| ... | ... | @@ -405,25 +442,14 @@ fn closed(path: &Path) -> Result<Connection> { |
| 405 | 442 | } |
| 406 | 443 | |
| 407 | 444 | /// A closed cache's base stamp and how many edits wait. |
| 408 | fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { | |
| 409 | let connection = closed(path)?; | |
| 410 | let base = base::stamp(&connection, base::Image::Base)? | |
| 411 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | |
| 445 | fn peek(connection: &Connection) -> Result<(onestore::Stamp, u64)> { | |
| 446 | let base = base::base_stamp(connection)?; | |
| 412 | 447 | let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; |
| 413 | 448 | Ok((base, unsigned(queued)?)) |
| 414 | 449 | } |
| 415 | 450 | |
| 416 | /// A closed cache's base image, if its stamp is `stamp`: the section file as it stands. | |
| 417 | fn copied(path: &Path, stamp: &onestore::Stamp) -> Result<Option<Vec<u8>>> { | |
| 418 | let connection = closed(path)?; | |
| 419 | if base::stamp(&connection, base::Image::Base)?.as_ref() != Some(stamp) { | |
| 420 | return Ok(None); | |
| 421 | } | |
| 422 | base::read(&connection, base::Image::Base) | |
| 423 | } | |
| 424 | ||
| 425 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { | |
| 426 | queue::load(connection, None) | |
| 451 | fn pending(connection: &Connection, key: Option<&Key>) -> Result<Vec<PendingEdit>> { | |
| 452 | queue::load(connection, key, None) | |
| 427 | 453 | } |
| 428 | 454 | |
| 429 | 455 | fn unsigned(value: i64) -> Result<u64> { |
crates/notebook/src/location.rs+1-1| ... | ... | @@ -110,7 +110,7 @@ pub(crate) fn claim( |
| 110 | 110 | if !replica.exists() { |
| 111 | 111 | continue; |
| 112 | 112 | } |
| 113 | let adoptable = match crate::peek(&replica) { | |
| 113 | let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { | |
| 114 | 114 | Ok((base, _)) => { |
| 115 | 115 | legacy || stamp(identity).is_some_and(|file| follows(&file, &base)) |
| 116 | 116 | } |
crates/notebook/src/migrate.rs+1| ... | ... | @@ -314,6 +314,7 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { |
| 314 | 314 | } |
| 315 | 315 | queue::insert( |
| 316 | 316 | transaction, |
| 317 | None, | |
| 317 | 318 | Some(crate::unsigned(edit.id)?), |
| 318 | 319 | current, |
| 319 | 320 | &edit.author, |
crates/notebook/src/package.rs created+236| ... | ... | @@ -0,0 +1,236 @@ |
| 1 | //! OneNote packages (`.onepkg`) and the other copies OneNote 2010's Save As writes. A | |
| 2 | //! package is a cabinet holding a notebook's tables of contents and sections at their paths | |
| 3 | //! in its folder, the recycle bin left out though its entry stays; OneNote writes each file | |
| 4 | //! outside any notebook (no `guidAncestor` or `crcName`). Its Unpack Notebook gives each file | |
| 5 | //! an identity of its own and lists it anew beside its stale entry; here the entry follows | |
| 6 | //! the file instead (`corpus/notebook-package`). OneNote compresses with LZX; this writes | |
| 7 | //! MSZIP, which every cabinet reader takes, and reads either. | |
| 8 | ||
| 9 | use crate::{ | |
| 10 | Error, Result, | |
| 11 | session::{Notebook, lists, moved}, | |
| 12 | }; | |
| 13 | use onestore::{TocEdit, op::Edit, page::Page}; | |
| 14 | use std::{ | |
| 15 | collections::BTreeMap, | |
| 16 | io::{self, Cursor, Read, Write}, | |
| 17 | path::Path, | |
| 18 | }; | |
| 19 | ||
| 20 | /// A package's files: each one's path in the notebook's folder, `/`-separated, and bytes. | |
| 21 | pub type Files = Vec<(String, Vec<u8>)>; | |
| 22 | ||
| 23 | fn invalid(message: &str) -> Error { | |
| 24 | io::Error::new(io::ErrorKind::InvalidData, message.to_owned()).into() | |
| 25 | } | |
| 26 | ||
| 27 | /// The files of `notebook` a package holds, as OneNote 2010 packs them: each folder's table | |
| 28 | /// of contents and sections, then its groups', the recycle bin and copies left out. `image` | |
| 29 | /// gives a section the caller holds edits for, by catalog path, as its replica leaves it. | |
| 30 | pub fn notebook_files( | |
| 31 | notebook: &Notebook, | |
| 32 | mut image: impl FnMut(&str) -> Option<Vec<u8>>, | |
| 33 | ) -> Result<Files> { | |
| 34 | let mut files = Vec::new(); | |
| 35 | let mut folders = vec![notebook.catalog()]; | |
| 36 | while let Some(folder) = folders.pop() { | |
| 37 | let Some(toc) = &folder.toc else { | |
| 38 | continue; | |
| 39 | }; | |
| 40 | files.push(( | |
| 41 | join(&folder.path, &toc.filename), | |
| 42 | notebook.read_toc(&folder.path)?, | |
| 43 | )); | |
| 44 | for section in folder.sections.iter().filter(|section| !section.copy) { | |
| 45 | let bytes = match image(&section.path) { | |
| 46 | Some(bytes) => bytes, | |
| 47 | None => notebook.read_section(&section.path)?, | |
| 48 | }; | |
| 49 | files.push((section.path.clone(), bytes)); | |
| 50 | } | |
| 51 | folders.extend( | |
| 52 | (folder.groups.iter().rev()) | |
| 53 | .filter(|group| group.path.rsplit('/').next() != Some("OneNote_RecycleBin")), | |
| 54 | ); | |
| 55 | } | |
| 56 | Ok(files) | |
| 57 | } | |
| 58 | ||
| 59 | fn join(folder: &str, name: &str) -> String { | |
| 60 | if folder.is_empty() { | |
| 61 | name.to_owned() | |
| 62 | } else { | |
| 63 | format!("{folder}/{name}") | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /// A package holding `files`, each taken out of its notebook as OneNote writes them. | |
| 68 | pub fn pack(files: Files) -> Result<Vec<u8>> { | |
| 69 | let mut builder = cab::CabinetBuilder::new(); | |
| 70 | let folder = builder.add_folder(cab::CompressionType::MsZip); | |
| 71 | for (path, _) in &files { | |
| 72 | folder.add_file(path.replace('/', "\\")); | |
| 73 | } | |
| 74 | let mut writer = builder.build(Cursor::new(Vec::new()))?; | |
| 75 | let mut files = files.into_iter(); | |
| 76 | while let Some(mut file) = writer.next_file()? { | |
| 77 | let (_, mut bytes) = files.next().ok_or_else(|| invalid("A file went missing"))?; | |
| 78 | onestore::place_image(&mut bytes, None)?; | |
| 79 | file.write_all(&bytes)?; | |
| 80 | } | |
| 81 | Ok(writer.finish()?.into_inner()) | |
| 82 | } | |
| 83 | ||
| 84 | /// The files the package `bytes` holds, refusing a path that leaves the notebook's folder | |
| 85 | /// and more than `limit` bytes unpacked. | |
| 86 | pub fn read(bytes: &[u8], limit: u64) -> Result<Files> { | |
| 87 | let mut cabinet = cab::Cabinet::new(Cursor::new(bytes))?; | |
| 88 | let mut listed = Vec::new(); | |
| 89 | let mut total = 0u64; | |
| 90 | for folder in cabinet.folder_entries() { | |
| 91 | for file in folder.file_entries() { | |
| 92 | total += u64::from(file.uncompressed_size()); | |
| 93 | listed.push(file.name().to_owned()); | |
| 94 | } | |
| 95 | } | |
| 96 | if total > limit { | |
| 97 | return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); | |
| 98 | } | |
| 99 | let mut files = Vec::new(); | |
| 100 | for name in listed { | |
| 101 | let path = name.replace('\\', "/"); | |
| 102 | if !path.split('/').all(component) { | |
| 103 | return Err(invalid("The package names a file outside its notebook")); | |
| 104 | } | |
| 105 | let mut bytes = Vec::new(); | |
| 106 | cabinet.read_file(&name)?.read_to_end(&mut bytes)?; | |
| 107 | files.push((path, bytes)); | |
| 108 | } | |
| 109 | Ok(files) | |
| 110 | } | |
| 111 | ||
| 112 | fn component(name: &str) -> bool { | |
| 113 | !name.is_empty() && !name.contains([':', '\0']) && name != "." && name != ".." | |
| 114 | } | |
| 115 | ||
| 116 | /// Writes package `files` to the new folder `root` as OneNote 2010's Unpack Notebook does: | |
| 117 | /// each table of contents and section takes a new identity, placed under its folder's table | |
| 118 | /// of contents, whose entries follow. | |
| 119 | pub fn unpack(mut files: Files, root: &Path) -> Result<()> { | |
| 120 | let folder = |path: &str| { | |
| 121 | path.rsplit_once('/') | |
| 122 | .map_or("", |(folder, _)| folder) | |
| 123 | .to_owned() | |
| 124 | }; | |
| 125 | let toc = |path: &str| path.to_ascii_lowercase().ends_with(".onetoc2"); | |
| 126 | let mut tocs: BTreeMap<String, usize> = BTreeMap::new(); | |
| 127 | for (at, (path, _)) in files.iter().enumerate() { | |
| 128 | if toc(path) && tocs.insert(folder(path), at).is_some() { | |
| 129 | return Err(invalid( | |
| 130 | "A folder of the package holds two tables of contents", | |
| 131 | )); | |
| 132 | } | |
| 133 | } | |
| 134 | // Parents first: a group's table of contents is placed under its parent's new identity. | |
| 135 | let mut identities: BTreeMap<String, ([u8; 16], [u8; 16])> = BTreeMap::new(); | |
| 136 | let mut ordered: Vec<(&String, &usize)> = tocs.iter().collect(); | |
| 137 | ordered.sort_by_key(|(folder, _)| folder.split('/').count() * usize::from(!folder.is_empty())); | |
| 138 | for (path, at) in ordered { | |
| 139 | let image = &mut files[*at].1; | |
| 140 | let old = onestore::Store::parse(image)?.header.file_id; | |
| 141 | let new = onestore::reidentify(image)?; | |
| 142 | let parent = (!path.is_empty()).then(|| folder(path)); | |
| 143 | let placed = match &parent { | |
| 144 | Some(parent) => { | |
| 145 | let (_, ancestor) = identities | |
| 146 | .get(parent) | |
| 147 | .ok_or_else(|| invalid("A section group's folder has no parent"))?; | |
| 148 | Some((*ancestor, path.rsplit('/').next().unwrap_or_default())) | |
| 149 | } | |
| 150 | None => None, | |
| 151 | }; | |
| 152 | onestore::place_image(image, placed)?; | |
| 153 | identities.insert(path.clone(), (old, new)); | |
| 154 | } | |
| 155 | // The entries each table of contents lists, by their files' old identities. | |
| 156 | let mut followed: BTreeMap<String, Vec<TocEdit>> = BTreeMap::new(); | |
| 157 | for (path, image) in &mut files { | |
| 158 | if toc(path) { | |
| 159 | continue; | |
| 160 | } | |
| 161 | let home = folder(path); | |
| 162 | let Some((_, ancestor)) = identities.get(&home) else { | |
| 163 | return Err(invalid("A section's folder has no table of contents")); | |
| 164 | }; | |
| 165 | let old = onestore::Store::parse(image)?.header.file_id; | |
| 166 | let new = onestore::reidentify(image)?; | |
| 167 | onestore::place_image( | |
| 168 | image, | |
| 169 | Some((*ancestor, path.rsplit('/').next().unwrap_or_default())), | |
| 170 | )?; | |
| 171 | (followed.entry(home).or_default()).push(TocEdit::Reidentify { | |
| 172 | identity: old, | |
| 173 | with: new, | |
| 174 | }); | |
| 175 | } | |
| 176 | for (path, (old, new)) in &identities { | |
| 177 | if !path.is_empty() { | |
| 178 | let edit = TocEdit::Reidentify { | |
| 179 | identity: *old, | |
| 180 | with: *new, | |
| 181 | }; | |
| 182 | followed.entry(folder(path)).or_default().push(edit); | |
| 183 | } | |
| 184 | } | |
| 185 | for (home, edits) in followed { | |
| 186 | let image = &mut files[tocs[&home]].1; | |
| 187 | let mut listed = Vec::new(); | |
| 188 | for edit in edits { | |
| 189 | if let TocEdit::Reidentify { identity, .. } = &edit | |
| 190 | && lists(image, *identity)? | |
| 191 | { | |
| 192 | listed.push(edit); | |
| 193 | } | |
| 194 | } | |
| 195 | if let Some(transaction) = onestore::edit_table_of_contents(image, &listed)? { | |
| 196 | transaction.apply(image)?; | |
| 197 | } | |
| 198 | } | |
| 199 | std::fs::create_dir(root)?; | |
| 200 | for (path, bytes) in &files { | |
| 201 | let file = root.join(path); | |
| 202 | if let Some(parent) = file.parent() { | |
| 203 | std::fs::create_dir_all(parent)?; | |
| 204 | } | |
| 205 | std::fs::File::create_new(file)?.write_all(bytes)?; | |
| 206 | } | |
| 207 | Ok(()) | |
| 208 | } | |
| 209 | ||
| 210 | /// The section `image` holds as Save As writes a copy of it: an identity of its own, outside | |
| 211 | /// any notebook. | |
| 212 | pub fn section_copy(mut image: Vec<u8>) -> Result<Vec<u8>> { | |
| 213 | onestore::reidentify(&mut image)?; | |
| 214 | onestore::place_image(&mut image, None)?; | |
| 215 | Ok(image) | |
| 216 | } | |
| 217 | ||
| 218 | /// A new section, named `name` and coloured `color` (COLORREF), holding `page` as Save As | |
| 219 | /// writes a page: the page keeps its identity, title, date and creation time. | |
| 220 | pub fn page_section(page: &Page, name: &str, color: Option<u32>, author: &str) -> Result<Vec<u8>> { | |
| 221 | let mut image = onestore::create_empty_section(name, color)?; | |
| 222 | let arena = onestore::Arena::default(); | |
| 223 | let mut section = onestore::Section::open(&arena, image.clone())?; | |
| 224 | section.apply( | |
| 225 | author, | |
| 226 | &Edit { | |
| 227 | at: crate::now(), | |
| 228 | ops: vec![moved(page, author)?], | |
| 229 | }, | |
| 230 | )?; | |
| 231 | if let Some(transaction) = section.seal()? { | |
| 232 | transaction.apply(&mut image)?; | |
| 233 | } | |
| 234 | onestore::place_image(&mut image, None)?; | |
| 235 | Ok(image) | |
| 236 | } |
crates/notebook/src/queue.rs+152-29| ... | ... | @@ -1,14 +1,23 @@ |
| 1 | 1 | //! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes |
| 2 | 2 | //! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them. |
| 3 | //! | |
| 4 | //! A password-protected section's queue keeps none of it in the clear: each edit and | |
| 5 | //! payload is sealed with AES-256-GCM (a random nonce, the row's kind as associated data) | |
| 6 | //! and payloads are named by HMAC-SHA256, both under keys HMAC-SHA256 derives from the | |
| 7 | //! section's own key. Only the author's name and the payload names stay readable. | |
| 3 | 8 | |
| 4 | 9 | use crate::{PendingEdit, Result, signed, unsigned}; |
| 10 | use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; | |
| 11 | use hmac::{Hmac, Mac}; | |
| 5 | 12 | use onestore::{ |
| 6 | 13 | op::{Edit, Op, PageOp, SectionOp, TableEdit}, |
| 7 | 14 | page::{PageObject, PageParagraph, ParagraphContent, TableCell}, |
| 15 | protected::Key, | |
| 8 | 16 | }; |
| 9 | 17 | use rusqlite::{Connection, OptionalExtension, params}; |
| 10 | 18 | use sha2::{Digest, Sha256}; |
| 11 | 19 | use std::{io, sync::Arc}; |
| 20 | use zeroize::Zeroizing; | |
| 12 | 21 | |
| 13 | 22 | type Payload = Option<Arc<[u8]>>; |
| 14 | 23 | |
| ... | ... | @@ -92,16 +101,79 @@ fn damaged(message: &'static str) -> crate::Error { |
| 92 | 101 | io::Error::new(io::ErrorKind::InvalidData, message).into() |
| 93 | 102 | } |
| 94 | 103 | |
| 95 | /// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by hash; | |
| 104 | /// A key `key` derives for `purpose`. | |
| 105 | fn derived(key: &Key, purpose: &[u8]) -> Zeroizing<[u8; 32]> { | |
| 106 | let mut mac = | |
| 107 | <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key.secret()).expect("any key length"); | |
| 108 | mac.update(purpose); | |
| 109 | Zeroizing::new(mac.finalize().into_bytes().into()) | |
| 110 | } | |
| 111 | ||
| 112 | fn cipher(key: &Key) -> Aes256Gcm { | |
| 113 | Aes256Gcm::new_from_slice(&*derived(key, b"Snowbound queue v1")).expect("a 32-byte key") | |
| 114 | } | |
| 115 | ||
| 116 | /// A payload's name: its SHA-256, or in a protected section's queue an HMAC. | |
| 117 | fn name(key: Option<&Key>, bytes: &[u8]) -> String { | |
| 118 | match key { | |
| 119 | Some(key) => { | |
| 120 | let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(&*derived( | |
| 121 | key, | |
| 122 | b"Snowbound payload names v1", | |
| 123 | )) | |
| 124 | .expect("any key length"); | |
| 125 | mac.update(bytes); | |
| 126 | hex(&mac.finalize().into_bytes()) | |
| 127 | } | |
| 128 | None => hex(&Sha256::digest(bytes)), | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | /// `clear` sealed under `key` as `kind`: a random nonce, then the ciphertext and tag. | |
| 133 | fn seal(key: &Key, kind: &[u8], clear: &[u8]) -> Result<Vec<u8>> { | |
| 134 | let mut nonce = [0; 12]; | |
| 135 | getrandom::fill(&mut nonce).map_err(|_| io::Error::other("System random source failed"))?; | |
| 136 | let sealed = cipher(key) | |
| 137 | .encrypt( | |
| 138 | &nonce.into(), | |
| 139 | aes_gcm::aead::Payload { | |
| 140 | msg: clear, | |
| 141 | aad: kind, | |
| 142 | }, | |
| 143 | ) | |
| 144 | .map_err(|_| io::Error::other("A queued edit could not be sealed"))?; | |
| 145 | Ok([&nonce[..], &sealed].concat()) | |
| 146 | } | |
| 147 | ||
| 148 | /// The inverse of `seal`. | |
| 149 | fn open(key: &Key, kind: &[u8], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>> { | |
| 150 | let (nonce, sealed) = sealed | |
| 151 | .split_first_chunk::<12>() | |
| 152 | .ok_or_else(|| damaged("A sealed queue row is truncated"))?; | |
| 153 | Ok(Zeroizing::new( | |
| 154 | cipher(key) | |
| 155 | .decrypt( | |
| 156 | &(*nonce).into(), | |
| 157 | aes_gcm::aead::Payload { | |
| 158 | msg: sealed, | |
| 159 | aad: kind, | |
| 160 | }, | |
| 161 | ) | |
| 162 | .map_err(|_| damaged("A sealed queue row does not open under the section's key"))?, | |
| 163 | )) | |
| 164 | } | |
| 165 | ||
| 166 | /// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by name; | |
| 96 | 167 | /// returns the edit's id. |
| 97 | 168 | pub(crate) fn insert( |
| 98 | 169 | connection: &Connection, |
| 170 | key: Option<&Key>, | |
| 99 | 171 | id: Option<u64>, |
| 100 | 172 | batch: i64, |
| 101 | 173 | author: &str, |
| 102 | 174 | edit: &Edit, |
| 103 | 175 | ) -> Result<u64> { |
| 104 | let (text, names) = encode(connection, edit)?; | |
| 176 | let (text, names) = encode(connection, key, edit)?; | |
| 105 | 177 | connection.execute( |
| 106 | 178 | "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)", |
| 107 | 179 | params![id.map(signed).transpose()?, batch, author, text, names], |
| ... | ... | @@ -114,8 +186,13 @@ fn hex(digest: &[u8]) -> String { |
| 114 | 186 | } |
| 115 | 187 | |
| 116 | 188 | /// Replaces a stored edit's ops. |
| 117 | pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> { | |
| 118 | let (text, names) = encode(connection, edit)?; | |
| 189 | pub(crate) fn rewrite( | |
| 190 | connection: &Connection, | |
| 191 | key: Option<&Key>, | |
| 192 | id: u64, | |
| 193 | edit: &Edit, | |
| 194 | ) -> Result<()> { | |
| 195 | let (text, names) = encode(connection, key, edit)?; | |
| 119 | 196 | connection.execute( |
| 120 | 197 | "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3", |
| 121 | 198 | params![text, names, signed(id)?], |
| ... | ... | @@ -123,26 +200,38 @@ pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<( |
| 123 | 200 | Ok(()) |
| 124 | 201 | } |
| 125 | 202 | |
| 126 | /// The serialized edit without payload bytes and the payload names, storing the payloads. | |
| 127 | fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String>)> { | |
| 203 | /// The serialized edit without payload bytes and the payload names, storing the payloads; | |
| 204 | /// sealed under `key` for a protected section. | |
| 205 | fn encode( | |
| 206 | connection: &Connection, | |
| 207 | key: Option<&Key>, | |
| 208 | edit: &Edit, | |
| 209 | ) -> Result<(String, Option<String>)> { | |
| 128 | 210 | let mut edit = edit.clone(); |
| 129 | 211 | let mut names: Vec<Option<String>> = Vec::new(); |
| 130 | 212 | let mut failure = None; |
| 131 | 213 | slots(&mut edit, |payload| { |
| 132 | 214 | let name = payload.take().map(|bytes| { |
| 133 | let digest = Sha256::digest(&bytes); | |
| 134 | if let Err(error) = connection.execute( | |
| 135 | "INSERT INTO payloads(sha256, bytes) VALUES (?1, ?2) ON CONFLICT DO NOTHING", | |
| 136 | params![&digest[..], &bytes[..]], | |
| 137 | ) { | |
| 215 | let name = name(key, &bytes); | |
| 216 | let stored = match key { | |
| 217 | Some(key) => seal(key, b"payload", &bytes), | |
| 218 | None => Ok(bytes.to_vec()), | |
| 219 | }; | |
| 220 | let stored = stored.and_then(|stored| { | |
| 221 | Ok(connection.execute( | |
| 222 | "INSERT INTO payloads(sha256, bytes) VALUES (unhex(?1), ?2) ON CONFLICT DO NOTHING", | |
| 223 | params![&name, &stored[..]], | |
| 224 | )?) | |
| 225 | }); | |
| 226 | if let Err(error) = stored { | |
| 138 | 227 | failure.get_or_insert(error); |
| 139 | 228 | } |
| 140 | hex(&digest) | |
| 229 | name | |
| 141 | 230 | }); |
| 142 | 231 | names.push(name); |
| 143 | 232 | }); |
| 144 | 233 | if let Some(error) = failure { |
| 145 | return Err(error.into()); | |
| 234 | return Err(error); | |
| 146 | 235 | } |
| 147 | 236 | let names = names |
| 148 | 237 | .iter() |
| ... | ... | @@ -150,15 +239,39 @@ fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String |
| 150 | 239 | .then(|| serde_json::to_string(&names)) |
| 151 | 240 | .transpose() |
| 152 | 241 | .map_err(io::Error::other)?; |
| 153 | Ok(( | |
| 154 | serde_json::to_string(&edit).map_err(io::Error::other)?, | |
| 155 | names, | |
| 156 | )) | |
| 242 | let text = Zeroizing::new(serde_json::to_string(&edit).map_err(io::Error::other)?); | |
| 243 | let text = match key { | |
| 244 | Some(key) => { | |
| 245 | use base64::Engine; | |
| 246 | base64::engine::general_purpose::STANDARD.encode(seal(key, b"edit", text.as_bytes())?) | |
| 247 | } | |
| 248 | None => text.to_string(), | |
| 249 | }; | |
| 250 | Ok((text, names)) | |
| 157 | 251 | } |
| 158 | 252 | |
| 159 | fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result<Edit> { | |
| 160 | let mut edit: Edit = serde_json::from_str(text) | |
| 161 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | |
| 253 | /// A stored edit's ops, without its payloads. | |
| 254 | pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result<Edit> { | |
| 255 | Ok(match key { | |
| 256 | Some(key) => { | |
| 257 | use base64::Engine; | |
| 258 | let sealed = base64::engine::general_purpose::STANDARD | |
| 259 | .decode(text) | |
| 260 | .map_err(|_| damaged("A sealed queued edit is damaged"))?; | |
| 261 | serde_json::from_slice(&open(key, b"edit", &sealed)?) | |
| 262 | } | |
| 263 | None => serde_json::from_str(text), | |
| 264 | } | |
| 265 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?) | |
| 266 | } | |
| 267 | ||
| 268 | fn decode( | |
| 269 | connection: &Connection, | |
| 270 | key: Option<&Key>, | |
| 271 | text: &str, | |
| 272 | names: Option<String>, | |
| 273 | ) -> Result<Edit> { | |
| 274 | let mut edit = parse(key, text)?; | |
| 162 | 275 | let Some(names) = names else { |
| 163 | 276 | return Ok(edit); |
| 164 | 277 | }; |
| ... | ... | @@ -176,18 +289,24 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result< |
| 176 | 289 | let Some(name) = name else { |
| 177 | 290 | return; |
| 178 | 291 | }; |
| 179 | match query | |
| 292 | let bytes = query | |
| 180 | 293 | .query_row([&name], |row| row.get::<_, Vec<u8>>(0)) |
| 181 | .optional() | |
| 182 | { | |
| 183 | Ok(Some(bytes)) if hex(&Sha256::digest(&bytes)) == name => { | |
| 294 | .optional(); | |
| 295 | let bytes = match (key, bytes) { | |
| 296 | (Some(key), Ok(Some(sealed))) => { | |
| 297 | open(key, b"payload", &sealed).map(|bytes| Some(bytes.to_vec())) | |
| 298 | } | |
| 299 | (_, bytes) => bytes.map_err(Into::into), | |
| 300 | }; | |
| 301 | match bytes { | |
| 302 | Ok(Some(bytes)) if self::name(key, &bytes) == name => { | |
| 184 | 303 | *payload = Some(Arc::from(bytes)); |
| 185 | 304 | } |
| 186 | 305 | Ok(_) => { |
| 187 | 306 | failure.get_or_insert(damaged("A queued payload is missing or damaged")); |
| 188 | 307 | } |
| 189 | 308 | Err(error) => { |
| 190 | failure.get_or_insert(error.into()); | |
| 309 | failure.get_or_insert(error); | |
| 191 | 310 | } |
| 192 | 311 | } |
| 193 | 312 | }); |
| ... | ... | @@ -201,7 +320,11 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result< |
| 201 | 320 | } |
| 202 | 321 | |
| 203 | 322 | /// Queued edits, oldest first; of one batch when `batch` is given. |
| 204 | pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<PendingEdit>> { | |
| 323 | pub(crate) fn load( | |
| 324 | connection: &Connection, | |
| 325 | key: Option<&Key>, | |
| 326 | batch: Option<i64>, | |
| 327 | ) -> Result<Vec<PendingEdit>> { | |
| 205 | 328 | let mut query = connection.prepare_cached( |
| 206 | 329 | "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id", |
| 207 | 330 | )?; |
| ... | ... | @@ -211,7 +334,7 @@ pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<Pe |
| 211 | 334 | edits.push(PendingEdit { |
| 212 | 335 | id: unsigned(row.get(0)?)?, |
| 213 | 336 | author: row.get(1)?, |
| 214 | edit: decode(connection, &row.get::<_, String>(2)?, row.get(3)?)?, | |
| 337 | edit: decode(connection, key, &row.get::<_, String>(2)?, row.get(3)?)?, | |
| 215 | 338 | }); |
| 216 | 339 | } |
| 217 | 340 | Ok(edits) |
| ... | ... | @@ -304,7 +427,7 @@ mod tests { |
| 304 | 427 | }) |
| 305 | 428 | .collect(), |
| 306 | 429 | }; |
| 307 | let id = insert(&connection, None, 1, "Author", &edit).unwrap(); | |
| 430 | let id = insert(&connection, None, None, 1, "Author", &edit).unwrap(); | |
| 308 | 431 | let stored: String = connection |
| 309 | 432 | .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| { |
| 310 | 433 | row.get(0) |
| ... | ... | @@ -315,7 +438,7 @@ mod tests { |
| 315 | 438 | .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) |
| 316 | 439 | .unwrap(); |
| 317 | 440 | assert_eq!(payloads, 1); |
| 318 | let loaded = load(&connection, None).unwrap(); | |
| 441 | let loaded = load(&connection, None, None).unwrap(); | |
| 319 | 442 | assert_eq!(loaded[0].edit, edit); |
| 320 | 443 | let Op::Page { |
| 321 | 444 | op: |
crates/notebook/src/recovery.rs+18-15| ... | ... | @@ -21,11 +21,22 @@ pub struct RecoverySummary { |
| 21 | 21 | /// Read-only recovery evidence; it cannot publish or acknowledge an edit. |
| 22 | 22 | pub struct Recovery { |
| 23 | 23 | connection: Connection, |
| 24 | /// A password-protected section's key, which opens its sealed queue. | |
| 25 | key: Option<Key>, | |
| 24 | 26 | } |
| 25 | 27 | |
| 26 | 28 | impl Recovery { |
| 27 | 29 | /// Opens an exported archive without migration or conversion into a writable replica. |
| 28 | 30 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { |
| 31 | Self::open_with(path.as_ref(), None) | |
| 32 | } | |
| 33 | ||
| 34 | /// `open` for an archive of a password-protected section, under its `key`. | |
| 35 | pub fn open_unlocked(path: impl AsRef<Path>, key: &Key) -> Result<Self> { | |
| 36 | Self::open_with(path.as_ref(), Some(key.clone())) | |
| 37 | } | |
| 38 | ||
| 39 | fn open_with(path: &Path, key: Option<Key>) -> Result<Self> { | |
| 29 | 40 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; |
| 30 | 41 | connection.busy_timeout(Duration::ZERO)?; |
| 31 | 42 | connection.execute_batch("BEGIN")?; |
| ... | ... | @@ -48,10 +59,10 @@ impl Recovery { |
| 48 | 59 | ) |
| 49 | 60 | .into()); |
| 50 | 61 | } |
| 51 | let recovery = Self { connection }; | |
| 62 | let recovery = Self { connection, key }; | |
| 52 | 63 | recovery.snapshot()?; |
| 53 | for edit in pending(&recovery.connection)? { | |
| 54 | sync::status(&recovery.connection, edit.id)?; | |
| 64 | for edit in recovery.pending()? { | |
| 65 | recovery.status(edit.id)?; | |
| 55 | 66 | } |
| 56 | 67 | receipts(&recovery.connection)?; |
| 57 | 68 | Ok(recovery) |
| ... | ... | @@ -63,31 +74,23 @@ impl Recovery { |
| 63 | 74 | |
| 64 | 75 | /// The image the archived queue leaves, as `Replica::snapshot` gives it. |
| 65 | 76 | pub fn snapshot(&self) -> Result<Vec<u8>> { |
| 66 | working::image(&self.connection) | |
| 67 | } | |
| 68 | ||
| 69 | fn base(&self) -> Result<Vec<u8>> { | |
| 70 | Ok( | |
| 71 | base::read(&self.connection, base::Image::Base)?.ok_or_else(|| { | |
| 72 | io::Error::new(io::ErrorKind::InvalidData, "The archive has no base image") | |
| 73 | })?, | |
| 74 | ) | |
| 77 | working::image(&self.connection, self.key.as_ref()) | |
| 75 | 78 | } |
| 76 | 79 | |
| 77 | 80 | /// The last observed remote image. |
| 78 | 81 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 79 | 82 | match base::read(&self.connection, base::Image::Remote)? { |
| 80 | 83 | Some(image) => Ok(image), |
| 81 | None => self.base(), | |
| 84 | None => base::base(&self.connection), | |
| 82 | 85 | } |
| 83 | 86 | } |
| 84 | 87 | |
| 85 | 88 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { |
| 86 | pending(&self.connection) | |
| 89 | pending(&self.connection, self.key.as_ref()) | |
| 87 | 90 | } |
| 88 | 91 | |
| 89 | 92 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { |
| 90 | sync::status(&self.connection, id) | |
| 93 | sync::status(&self.connection, id, self.key.as_ref()) | |
| 91 | 94 | } |
| 92 | 95 | |
| 93 | 96 | pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> { |
crates/notebook/src/session.rs+334-118| ... | ... | @@ -10,9 +10,10 @@ use onestore::{ |
| 10 | 10 | document::Document, |
| 11 | 11 | op::{Edit, Op, SectionOp}, |
| 12 | 12 | page::Page, |
| 13 | protected, | |
| 13 | 14 | }; |
| 14 | 15 | use std::{ |
| 15 | collections::BTreeMap, | |
| 16 | collections::{BTreeMap, BTreeSet}, | |
| 16 | 17 | io, |
| 17 | 18 | io::Write, |
| 18 | 19 | path::{Path, PathBuf}, |
| ... | ... | @@ -99,14 +100,9 @@ pub trait Storage: Send + Sync { |
| 99 | 100 | fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; |
| 100 | 101 | /// Publishes a transaction made on the file's current image. |
| 101 | 102 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>; |
| 102 | } | |
| 103 | ||
| 104 | /// A password-protected section as `Notebook::unlock` read it. | |
| 105 | #[cfg(feature = "protected")] | |
| 106 | pub struct Unlocked { | |
| 107 | pub pages: Vec<(ExGuid, Page)>, | |
| 108 | /// The stored section the pages came from, which an edit must still find in place. | |
| 109 | snapshot: Vec<u8>, | |
| 103 | /// Puts the file `with` in the place of the section or TOC at `path` under the coordination | |
| 104 | /// its writers take, provided `path` still has `base`'s stamp. | |
| 105 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>; | |
| 110 | 106 | } |
| 111 | 107 | |
| 112 | 108 | /// A mounted notebook directory. |
| ... | ... | @@ -235,6 +231,14 @@ impl Storage for Directory { |
| 235 | 231 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { |
| 236 | 232 | Ok(transaction.commit_file(self.path(path))?) |
| 237 | 233 | } |
| 234 | ||
| 235 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { | |
| 236 | Ok(onestore::supersede_file( | |
| 237 | self.path(path), | |
| 238 | base, | |
| 239 | self.path(with), | |
| 240 | )?) | |
| 241 | } | |
| 238 | 242 | } |
| 239 | 243 | |
| 240 | 244 | /// A notebook directory on an SMB share, reached through the native-compatible client. |
| ... | ... | @@ -275,7 +279,7 @@ impl Storage for Share { |
| 275 | 279 | fn exists(&self, path: &str) -> bool { |
| 276 | 280 | let (folder, name) = split(path); |
| 277 | 281 | self.client |
| 278 | .read_dir(&self.path(folder), 100_000) | |
| 282 | .read_dir(&self.path(folder), LIMITS.entries) | |
| 279 | 283 | .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name)) |
| 280 | 284 | } |
| 281 | 285 | |
| ... | ... | @@ -322,9 +326,15 @@ impl Storage for Share { |
| 322 | 326 | .client |
| 323 | 327 | .commit_transaction(&self.path(path), transaction)?) |
| 324 | 328 | } |
| 329 | ||
| 330 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { | |
| 331 | Ok(self | |
| 332 | .client | |
| 333 | .supersede(&self.path(path), base, &self.path(with))?) | |
| 334 | } | |
| 325 | 335 | } |
| 326 | 336 | |
| 327 | const LIMITS: discover::Limits = discover::Limits { | |
| 337 | pub(crate) const LIMITS: discover::Limits = discover::Limits { | |
| 328 | 338 | entries: 100_000, |
| 329 | 339 | bytes_per_file: 256 * 1024 * 1024, |
| 330 | 340 | depth: 64, |
| ... | ... | @@ -415,20 +425,16 @@ impl Notebook { |
| 415 | 425 | let catalog = storage.discover(&mut read, LIMITS)?; |
| 416 | 426 | let location = storage.location(); |
| 417 | 427 | let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new(); |
| 418 | let mut folders = vec![&catalog]; | |
| 419 | while let Some(folder) = folders.pop() { | |
| 420 | for section in &folder.sections { | |
| 421 | let identity = match (&root, &section.state) { | |
| 422 | (Some(_), discover::SectionState::Readable { document, .. }) => *document, | |
| 423 | (None, discover::SectionState::Readable { .. }) => section.file_id, | |
| 424 | _ => continue, | |
| 425 | }; | |
| 426 | claims | |
| 427 | .entry(replica_location(&location, section)) | |
| 428 | .or_default() | |
| 429 | .push((identity, &section.path)); | |
| 430 | } | |
| 431 | folders.extend(&folder.groups); | |
| 428 | for section in catalog.sections() { | |
| 429 | let identity = match (&root, &section.state) { | |
| 430 | (Some(_), discover::SectionState::Readable { document, .. }) => *document, | |
| 431 | (None, discover::SectionState::Readable { .. }) => section.file_id, | |
| 432 | _ => continue, | |
| 433 | }; | |
| 434 | claims | |
| 435 | .entry(replica_location(&location, section)) | |
| 436 | .or_default() | |
| 437 | .push((identity, &section.path)); | |
| 432 | 438 | } |
| 433 | 439 | for (at, sections) in &claims { |
| 434 | 440 | let identities: Vec<[u8; 16]> = |
| ... | ... | @@ -447,6 +453,7 @@ impl Notebook { |
| 447 | 453 | listing, |
| 448 | 454 | }; |
| 449 | 455 | notebook.keep_listing(); |
| 456 | notebook.forget_superseded(); | |
| 450 | 457 | Ok(notebook) |
| 451 | 458 | } |
| 452 | 459 | |
| ... | ... | @@ -545,30 +552,23 @@ impl Notebook { |
| 545 | 552 | } |
| 546 | 553 | } |
| 547 | 554 | self.catalog = catalog; |
| 555 | self.forget_superseded(); | |
| 548 | 556 | Ok(changes) |
| 549 | 557 | } |
| 550 | 558 | |
| 551 | 559 | fn folder(&self, path: &str) -> Result<&discover::Folder> { |
| 552 | let mut folders = vec![&self.catalog]; | |
| 553 | while let Some(folder) = folders.pop() { | |
| 554 | if folder.path == path { | |
| 555 | return Ok(folder); | |
| 556 | } | |
| 557 | folders.extend(&folder.groups); | |
| 558 | } | |
| 559 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | |
| 560 | self.catalog | |
| 561 | .folders() | |
| 562 | .find(|folder| folder.path == path) | |
| 563 | .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) | |
| 560 | 564 | } |
| 561 | 565 | |
| 562 | 566 | /// A catalog section's path, refusing paths the catalog does not list. |
| 563 | 567 | fn section_path(&self, path: &str) -> Result<&discover::Section> { |
| 564 | let mut folders = vec![&self.catalog]; | |
| 565 | while let Some(folder) = folders.pop() { | |
| 566 | if let Some(section) = folder.sections.iter().find(|section| section.path == path) { | |
| 567 | return Ok(section); | |
| 568 | } | |
| 569 | folders.extend(&folder.groups); | |
| 570 | } | |
| 571 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | |
| 568 | self.catalog | |
| 569 | .sections() | |
| 570 | .find(|section| section.path == path) | |
| 571 | .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) | |
| 572 | 572 | } |
| 573 | 573 | |
| 574 | 574 | /// A folder's TOC path and file identity, creating the TOC when the folder has none |
| ... | ... | @@ -1051,6 +1051,56 @@ impl Notebook { |
| 1051 | 1051 | Ok(purged) |
| 1052 | 1052 | } |
| 1053 | 1053 | |
| 1054 | /// Empties the recycle bin as OneNote 2010's Empty Recycle Bin does: every page of | |
| 1055 | /// Deleted Pages goes in one revision, and every binned section's file goes, its entry | |
| 1056 | /// left in the bin's TOC (`corpus/recycle-bin-view`). | |
| 1057 | pub fn empty_recycle_bin(&mut self) -> Result<()> { | |
| 1058 | let Ok(bin) = self.folder(RECYCLE_BIN) else { | |
| 1059 | return Ok(()); | |
| 1060 | }; | |
| 1061 | let sections: Vec<String> = (bin.sections.iter()) | |
| 1062 | .filter(|section| !section.copy) | |
| 1063 | .map(|section| section.path.clone()) | |
| 1064 | .collect(); | |
| 1065 | for path in sections { | |
| 1066 | if !split(&path) | |
| 1067 | .1 | |
| 1068 | .eq_ignore_ascii_case("OneNote_DeletedPages.one") | |
| 1069 | { | |
| 1070 | self.storage.delete(&path)?; | |
| 1071 | continue; | |
| 1072 | } | |
| 1073 | let arena = onestore::Arena::default(); | |
| 1074 | let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?; | |
| 1075 | let pages: Vec<ExGuid> = section | |
| 1076 | .pages()? | |
| 1077 | .into_iter() | |
| 1078 | .map(|(space, ..)| space) | |
| 1079 | .collect(); | |
| 1080 | if pages.is_empty() { | |
| 1081 | continue; | |
| 1082 | } | |
| 1083 | section.apply( | |
| 1084 | "", | |
| 1085 | &Edit { | |
| 1086 | at: crate::now(), | |
| 1087 | ops: vec![Op::Section(SectionOp::Delete(pages))], | |
| 1088 | }, | |
| 1089 | )?; | |
| 1090 | if let Some(transaction) = section.seal()? { | |
| 1091 | self.storage.commit(&path, &transaction)?; | |
| 1092 | } | |
| 1093 | } | |
| 1094 | self.refresh().map(drop) | |
| 1095 | } | |
| 1096 | ||
| 1097 | /// The table of contents of the folder at catalog path `folder` as its file stores it. | |
| 1098 | pub fn read_toc(&self, folder: &str) -> Result<Vec<u8>> { | |
| 1099 | let toc = self.folder(folder)?.toc.as_ref(); | |
| 1100 | let toc = toc.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; | |
| 1101 | self.storage.read(&catalog_path(folder, &toc.filename)) | |
| 1102 | } | |
| 1103 | ||
| 1054 | 1104 | /// Moves a section or section group into the folder at catalog path `folder`, last, as |
| 1055 | 1105 | /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's |
| 1056 | 1106 | /// entry follows it. Returns its new catalog path. |
| ... | ... | @@ -1133,14 +1183,11 @@ impl Notebook { |
| 1133 | 1183 | let Some(link) = onestore::page::link::parse_internal_link(url) else { |
| 1134 | 1184 | return Ok(None); |
| 1135 | 1185 | }; |
| 1136 | let mut sections = Vec::new(); | |
| 1137 | let mut folders = vec![&self.catalog]; | |
| 1138 | while let Some(folder) = folders.pop() { | |
| 1139 | sections.extend(folder.sections.iter().filter(|section| { | |
| 1140 | matches!(section.state, discover::SectionState::Readable { .. }) | |
| 1141 | })); | |
| 1142 | folders.extend(&folder.groups); | |
| 1143 | } | |
| 1186 | let mut sections: Vec<_> = self | |
| 1187 | .catalog | |
| 1188 | .sections() | |
| 1189 | .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) | |
| 1190 | .collect(); | |
| 1144 | 1191 | sections.sort_by_key(|section| section.file_id != link.section); |
| 1145 | 1192 | let Some(page) = link.page else { |
| 1146 | 1193 | return Ok(sections |
| ... | ... | @@ -1168,56 +1215,172 @@ impl Notebook { |
| 1168 | 1215 | self.storage.read(&self.section_path(path)?.path) |
| 1169 | 1216 | } |
| 1170 | 1217 | |
| 1171 | /// The pages of a password-protected section: nothing is cached, and the decoded | |
| 1172 | /// buffers go when the pages have been built. | |
| 1173 | #[cfg(feature = "protected")] | |
| 1174 | pub fn unlock(&self, path: &str, password: &str) -> Result<Unlocked> { | |
| 1175 | let path = self.section_path(path)?.path.clone(); | |
| 1176 | let snapshot = self.storage.read(&path)?; | |
| 1177 | let pages = { | |
| 1178 | let store = Store::parse(&snapshot)?; | |
| 1179 | let index = RevisionIndex::parse(&store)?; | |
| 1180 | let unlocked = onestore::protected::UnlockedSection::open( | |
| 1181 | &index, | |
| 1182 | password, | |
| 1183 | onestore::protected::Limits::default(), | |
| 1184 | )?; | |
| 1185 | let document = unlocked.document()?; | |
| 1186 | document | |
| 1187 | .pages()? | |
| 1188 | .into_iter() | |
| 1189 | .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) | |
| 1190 | .collect::<Result<_>>()? | |
| 1191 | }; | |
| 1192 | Ok(Unlocked { pages, snapshot }) | |
| 1218 | /// The key of the password-protected section at catalog `path`, opened with `password`. | |
| 1219 | /// Edits this device queued before the section was protected elsewhere, held since, are | |
| 1220 | /// queued again under the key: each page they changed comes back as a copy, as a page | |
| 1221 | /// another client removed does, the section's pages having taken new identities. | |
| 1222 | pub fn unlock(&self, path: &str, password: &str) -> Result<protected::Key> { | |
| 1223 | let section = self.section_path(path)?; | |
| 1224 | let image = self.storage.read(&section.path)?; | |
| 1225 | let key = protected::Key::open(&image, password)?; | |
| 1226 | let held: Vec<PathBuf> = self | |
| 1227 | .superseded(&[section]) | |
| 1228 | .into_iter() | |
| 1229 | .filter(|(.., queued)| *queued > 0) | |
| 1230 | .map(|(replica, ..)| replica) | |
| 1231 | .collect(); | |
| 1232 | if !held.is_empty() { | |
| 1233 | let replica = | |
| 1234 | Replica::open_or_create(self.replica_path(path)?, Some(&key), || Ok(image))?; | |
| 1235 | for path in held { | |
| 1236 | // A queue sealed under an earlier password waits for that password. | |
| 1237 | let Ok(old) = Replica::open(&path) else { | |
| 1238 | continue; | |
| 1239 | }; | |
| 1240 | for (author, edit) in old.copies()? { | |
| 1241 | replica.apply(&author, edit)?; | |
| 1242 | } | |
| 1243 | drop(old); | |
| 1244 | remove_replica(&path)?; | |
| 1245 | } | |
| 1246 | } | |
| 1247 | Ok(key) | |
| 1193 | 1248 | } |
| 1194 | 1249 | |
| 1195 | /// Applies `edit` to pages of `unlocked` and stores it under the section's key, straight | |
| 1196 | /// to the file: nothing of a protected section is cached or queued. A section written | |
| 1197 | /// since `unlocked` was read refuses the edit; unlock again for its pages. | |
| 1198 | #[cfg(feature = "protected")] | |
| 1199 | pub fn apply_unlocked( | |
| 1250 | /// The replicas of the files the protected `locked` sections superseded when their | |
| 1251 | /// passwords were set (`onestore::protected::rekey` keeps the placement a file's header | |
| 1252 | /// names), each held so that no one opens it meanwhile, with how many edits it queues. Only | |
| 1253 | /// replicas no readable section names are read; one in use is left. | |
| 1254 | fn superseded( | |
| 1200 | 1255 | &self, |
| 1256 | locked: &[&discover::Section], | |
| 1257 | ) -> Vec<(PathBuf, rusqlite::Connection, u64)> { | |
| 1258 | let named: BTreeSet<PathBuf> = self | |
| 1259 | .catalog | |
| 1260 | .sections() | |
| 1261 | .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) | |
| 1262 | .filter_map(|section| self.replica_path(&section.path).ok()) | |
| 1263 | .collect(); | |
| 1264 | let placed: Vec<_> = locked | |
| 1265 | .iter() | |
| 1266 | .filter_map(|section| { | |
| 1267 | let (_, stamp) = self.read.found(&section.path)?; | |
| 1268 | Some((section.file_id, stamp.header, self.replica_folder(section))) | |
| 1269 | }) | |
| 1270 | .collect(); | |
| 1271 | let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect(); | |
| 1272 | let mut superseded = Vec::new(); | |
| 1273 | for folder in folders { | |
| 1274 | let Ok(entries) = std::fs::read_dir(folder) else { | |
| 1275 | continue; | |
| 1276 | }; | |
| 1277 | for path in entries.filter_map(|entry| Some(entry.ok()?.path())) { | |
| 1278 | if path | |
| 1279 | .extension() | |
| 1280 | .is_none_or(|extension| extension != "sqlite") | |
| 1281 | || named.contains(&path) | |
| 1282 | { | |
| 1283 | continue; | |
| 1284 | } | |
| 1285 | let Ok(held) = crate::closed(&path) else { | |
| 1286 | continue; | |
| 1287 | }; | |
| 1288 | let Ok((base, queued)) = crate::peek(&held) else { | |
| 1289 | continue; | |
| 1290 | }; | |
| 1291 | let Ok(header) = onestore::Header::parse(&base.header) else { | |
| 1292 | continue; | |
| 1293 | }; | |
| 1294 | if placed.iter().any(|(file, stamp, at)| { | |
| 1295 | at == folder | |
| 1296 | && base.header[128..148] == stamp[128..148] | |
| 1297 | && header.file_id != *file | |
| 1298 | }) { | |
| 1299 | superseded.push((path, held, queued)); | |
| 1300 | } | |
| 1301 | } | |
| 1302 | } | |
| 1303 | superseded | |
| 1304 | } | |
| 1305 | ||
| 1306 | /// Deletes the replicas holding nothing unpublished of files a protected section | |
| 1307 | /// superseded: their plaintext is the section's before its password. | |
| 1308 | fn forget_superseded(&self) { | |
| 1309 | let locked: Vec<_> = self | |
| 1310 | .catalog | |
| 1311 | .sections() | |
| 1312 | .filter(|section| matches!(section.state, discover::SectionState::Locked)) | |
| 1313 | .collect(); | |
| 1314 | if locked.is_empty() { | |
| 1315 | return; | |
| 1316 | } | |
| 1317 | for (replica, held, queued) in self.superseded(&locked) { | |
| 1318 | drop(held); | |
| 1319 | if queued == 0 { | |
| 1320 | let _ = remove_replica(&replica); | |
| 1321 | } | |
| 1322 | } | |
| 1323 | } | |
| 1324 | ||
| 1325 | /// Sets, changes or removes the password of the section at catalog `path`, as OneNote | |
| 1326 | /// 2010 does: the section is written anew under new identities (`onestore::protected:: | |
| 1327 | /// rekey`), the file replaces the old one and its folder's TOC follows it. `key` opens | |
| 1328 | /// it as it stands; `password` protects it anew, or `None` leaves it unprotected. Its | |
| 1329 | /// replica, a cache of the old file, goes too, so its session must be closed and its | |
| 1330 | /// edits published first. Returns the new key. | |
| 1331 | pub fn set_password( | |
| 1332 | &mut self, | |
| 1201 | 1333 | path: &str, |
| 1202 | password: &str, | |
| 1203 | unlocked: &mut Unlocked, | |
| 1204 | author: &str, | |
| 1205 | edit: &Edit, | |
| 1206 | ) -> Result<()> { | |
| 1207 | let path = self.section_path(path)?.path.clone(); | |
| 1208 | let transaction = { | |
| 1209 | let store = Store::parse(&unlocked.snapshot)?; | |
| 1210 | let index = RevisionIndex::parse(&store)?; | |
| 1211 | onestore::protected::UnlockedSection::open( | |
| 1212 | &index, | |
| 1213 | password, | |
| 1214 | onestore::protected::Limits::default(), | |
| 1215 | )? | |
| 1216 | .apply(author, edit)? | |
| 1217 | }; | |
| 1218 | self.storage.commit(&path, &transaction)?; | |
| 1219 | transaction.apply(&mut unlocked.snapshot)?; | |
| 1220 | Ok(()) | |
| 1334 | key: Option<&protected::Key>, | |
| 1335 | password: Option<&str>, | |
| 1336 | ) -> Result<Option<protected::Key>> { | |
| 1337 | let section = self.section_path(path)?; | |
| 1338 | let replica = self.replica_path(path)?; | |
| 1339 | // Held until the file is replaced, so that no session queues edits to the old file. | |
| 1340 | let held = replica | |
| 1341 | .exists() | |
| 1342 | .then(|| crate::closed(&replica)) | |
| 1343 | .transpose()?; | |
| 1344 | if let Some(held) = &held | |
| 1345 | && crate::peek(held)?.1 > 0 | |
| 1346 | { | |
| 1347 | return Err(io::Error::new( | |
| 1348 | io::ErrorKind::WouldBlock, | |
| 1349 | "Edits to the section wait to be published", | |
| 1350 | ) | |
| 1351 | .into()); | |
| 1352 | } | |
| 1353 | let source = self.storage.read(&section.path)?; | |
| 1354 | let new = password.map(protected::Key::new).transpose()?; | |
| 1355 | let image = onestore::protected::rekey(&source, key, new.as_ref())?; | |
| 1356 | let (folder, filename) = split(&section.path); | |
| 1357 | // A dot file, which discovery and OneNote pass over, until it replaces the section. | |
| 1358 | let written = catalog_path(folder, &format!(".{filename}.snowbound")); | |
| 1359 | if self.storage.exists(&written) { | |
| 1360 | self.storage.delete(&written)?; | |
| 1361 | } | |
| 1362 | self.storage.create(&written, &image)?; | |
| 1363 | if let Err(error) = self | |
| 1364 | .storage | |
| 1365 | .supersede(&section.path, &Stamp::of(&source)?, &written) | |
| 1366 | { | |
| 1367 | let _ = self.storage.delete(&written); | |
| 1368 | return Err(error); | |
| 1369 | } | |
| 1370 | drop(held); | |
| 1371 | remove_replica(&replica)?; | |
| 1372 | let parent = self.folder(folder)?; | |
| 1373 | if let Some(identity) = Entry::of(parent, section).identity { | |
| 1374 | self.edit_toc( | |
| 1375 | folder, | |
| 1376 | &[onestore::TocEdit::Reidentify { | |
| 1377 | identity, | |
| 1378 | with: Store::parse(&image)?.header.file_id, | |
| 1379 | }], | |
| 1380 | )?; | |
| 1381 | } | |
| 1382 | self.refresh()?; | |
| 1383 | Ok(new) | |
| 1221 | 1384 | } |
| 1222 | 1385 | |
| 1223 | 1386 | /// Where the replica of the section at catalog `path` lives, in the notebook's |
| ... | ... | @@ -1250,22 +1413,16 @@ impl Notebook { |
| 1250 | 1413 | /// discovery read so that each is read once. |
| 1251 | 1414 | pub fn replicas(&mut self) -> Vec<Known> { |
| 1252 | 1415 | let mut images = self.read.take(); |
| 1253 | let mut sections = Vec::new(); | |
| 1254 | let mut folders = vec![&self.catalog]; | |
| 1255 | while let Some(folder) = folders.pop() { | |
| 1256 | for section in &folder.sections { | |
| 1257 | if matches!(section.state, discover::SectionState::Readable { .. }) { | |
| 1258 | sections.push(Known { | |
| 1259 | path: section.path.clone(), | |
| 1260 | replica: self.replica_path(&section.path).ok(), | |
| 1261 | found: self.read.found(&section.path), | |
| 1262 | image: images.remove(&section.path), | |
| 1263 | }); | |
| 1264 | } | |
| 1265 | } | |
| 1266 | folders.extend(folder.groups.iter().rev()); | |
| 1267 | } | |
| 1268 | sections | |
| 1416 | self.catalog | |
| 1417 | .sections() | |
| 1418 | .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) | |
| 1419 | .map(|section| Known { | |
| 1420 | path: section.path.clone(), | |
| 1421 | replica: self.replica_path(&section.path).ok(), | |
| 1422 | found: self.read.found(&section.path), | |
| 1423 | image: images.remove(&section.path), | |
| 1424 | }) | |
| 1425 | .collect() | |
| 1269 | 1426 | } |
| 1270 | 1427 | |
| 1271 | 1428 | /// Keeps the sections of a mounted notebook in sync while they are not open |
| ... | ... | @@ -1326,6 +1483,37 @@ impl Notebook { |
| 1326 | 1483 | path: &str, |
| 1327 | 1484 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1328 | 1485 | notify: impl Fn() + Send + 'static, |
| 1486 | ) -> Result<Section> { | |
| 1487 | self.open_section(path, None, connect, notify) | |
| 1488 | } | |
| 1489 | ||
| 1490 | /// `section` for a password-protected section, under the `key` `unlock` gave. | |
| 1491 | pub fn section_unlocked( | |
| 1492 | &self, | |
| 1493 | path: &str, | |
| 1494 | key: &protected::Key, | |
| 1495 | notify: impl Fn() + Send + 'static, | |
| 1496 | ) -> Result<Section> { | |
| 1497 | self.section_unlocked_with(path, key, |file| Ok(FileRemote(file.to_owned())), notify) | |
| 1498 | } | |
| 1499 | ||
| 1500 | /// `section_with` for a password-protected section, under its `key`. | |
| 1501 | pub fn section_unlocked_with<R: Remote + 'static>( | |
| 1502 | &self, | |
| 1503 | path: &str, | |
| 1504 | key: &protected::Key, | |
| 1505 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, | |
| 1506 | notify: impl Fn() + Send + 'static, | |
| 1507 | ) -> Result<Section> { | |
| 1508 | self.open_section(path, Some(key), connect, notify) | |
| 1509 | } | |
| 1510 | ||
| 1511 | fn open_section<R: Remote + 'static>( | |
| 1512 | &self, | |
| 1513 | path: &str, | |
| 1514 | key: Option<&protected::Key>, | |
| 1515 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, | |
| 1516 | notify: impl Fn() + Send + 'static, | |
| 1329 | 1517 | ) -> Result<Section> { |
| 1330 | 1518 | let section = self.section_path(path)?; |
| 1331 | 1519 | let (path, replicas) = (section.path.clone(), self.replica_folder(section)); |
| ... | ... | @@ -1355,7 +1543,7 @@ impl Notebook { |
| 1355 | 1543 | std::fs::create_dir_all(&replicas)?; |
| 1356 | 1544 | Ok(replica_file(&replicas, identity)) |
| 1357 | 1545 | }; |
| 1358 | Section::open_in(file, replica, connect, notify) | |
| 1546 | Section::open_in(file, key, replica, connect, notify) | |
| 1359 | 1547 | } |
| 1360 | 1548 | } |
| 1361 | 1549 | |
| ... | ... | @@ -1411,7 +1599,18 @@ pub struct StoredPage { |
| 1411 | 1599 | pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> { |
| 1412 | 1600 | let store = Store::parse(image)?; |
| 1413 | 1601 | let index = RevisionIndex::parse(&store)?; |
| 1414 | let document = Document::parse(&index)?; | |
| 1602 | pages_of(&Document::parse(&index)?) | |
| 1603 | } | |
| 1604 | ||
| 1605 | /// `stored_pages` of a password-protected section, under its `key`. | |
| 1606 | pub fn stored_pages_unlocked(image: &[u8], key: &protected::Key) -> Result<Vec<StoredPage>> { | |
| 1607 | let store = Store::parse(image)?; | |
| 1608 | let index = RevisionIndex::parse(&store)?; | |
| 1609 | let unlocked = protected::UnlockedSection::unlock(&index, key, Default::default())?; | |
| 1610 | pages_of(&unlocked.document()?) | |
| 1611 | } | |
| 1612 | ||
| 1613 | fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> { | |
| 1415 | 1614 | Ok(document |
| 1416 | 1615 | .pages()? |
| 1417 | 1616 | .into_iter() |
| ... | ... | @@ -1461,7 +1660,7 @@ fn component(name: &str) -> bool { |
| 1461 | 1660 | } |
| 1462 | 1661 | |
| 1463 | 1662 | /// Whether the TOC `image` holds has an entry for the file identity `file`. |
| 1464 | fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> { | |
| 1663 | pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> { | |
| 1465 | 1664 | let store = Store::parse(image)?; |
| 1466 | 1665 | let index = RevisionIndex::parse(&store)?; |
| 1467 | 1666 | let document = Document::parse(&index)?; |
| ... | ... | @@ -1508,6 +1707,19 @@ fn replica_location(notebook: &str, section: &discover::Section) -> String { |
| 1508 | 1707 | } |
| 1509 | 1708 | } |
| 1510 | 1709 | |
| 1710 | /// Deletes the closed replica at `replica`, with the files SQLite keeps beside it. | |
| 1711 | fn remove_replica(replica: &Path) -> io::Result<()> { | |
| 1712 | for suffix in ["", "-wal", "-shm"] { | |
| 1713 | let mut file = replica.as_os_str().to_owned(); | |
| 1714 | file.push(suffix); | |
| 1715 | match std::fs::remove_file(file) { | |
| 1716 | Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), | |
| 1717 | _ => {} | |
| 1718 | } | |
| 1719 | } | |
| 1720 | Ok(()) | |
| 1721 | } | |
| 1722 | ||
| 1511 | 1723 | /// The replica in `cache` of the section `identity` names. |
| 1512 | 1724 | pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { |
| 1513 | 1725 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); |
| ... | ... | @@ -1635,13 +1847,14 @@ impl Section { |
| 1635 | 1847 | })?; |
| 1636 | 1848 | Ok(replica_file(&folder, identity)) |
| 1637 | 1849 | }; |
| 1638 | Self::open_in(file, replicas, connect, notify) | |
| 1850 | Self::open_in(file, None, replicas, connect, notify) | |
| 1639 | 1851 | } |
| 1640 | 1852 | |
| 1641 | /// Opens the canonical section `file` through the replica `replica` names for its document | |
| 1642 | /// identity and image. | |
| 1853 | /// Opens the canonical section `file`, a protected one under `key`, through the replica | |
| 1854 | /// `replica` names for its document identity and image. | |
| 1643 | 1855 | fn open_in<R: Remote + 'static>( |
| 1644 | 1856 | file: PathBuf, |
| 1857 | key: Option<&protected::Key>, | |
| 1645 | 1858 | replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>, |
| 1646 | 1859 | mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1647 | 1860 | notify: impl Fn() + Send + 'static, |
| ... | ... | @@ -1650,7 +1863,7 @@ impl Section { |
| 1650 | 1863 | let store = Store::parse(&source)?; |
| 1651 | 1864 | let identity = RevisionIndex::parse(&store)?.root; |
| 1652 | 1865 | let cache = replica(&identity.guid, &source)?; |
| 1653 | let replica = Replica::open_or_create(&cache, || Ok(source))?; | |
| 1866 | let replica = Replica::open_or_create(&cache, key, || Ok(source))?; | |
| 1654 | 1867 | let remote = file.clone(); |
| 1655 | 1868 | Self::start(file, replica, move || connect(&remote), notify) |
| 1656 | 1869 | } |
| ... | ... | @@ -2015,3 +2228,6 @@ impl Drop for Section { |
| 2015 | 2228 | drop(self.worker.take()); |
| 2016 | 2229 | } |
| 2017 | 2230 | } |
| 2231 | ||
| 2232 | #[cfg(test)] | |
| 2233 | mod tests; |
crates/notebook/src/session/tests.rs created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | use super::*; | |
| 2 | ||
| 3 | /// A mounted notebook where another writer commits `revision` to the section file just | |
| 4 | /// before Snowbound's rewritten file takes its place. | |
| 5 | struct Racing { | |
| 6 | inner: Directory, | |
| 7 | revision: Mutex<Option<(String, Transaction)>>, | |
| 8 | } | |
| 9 | ||
| 10 | impl Racing { | |
| 11 | fn race(&self, path: &str) { | |
| 12 | let revision = self.revision.lock().unwrap().take(); | |
| 13 | if let Some((section, transaction)) = revision { | |
| 14 | assert_eq!(section, path); | |
| 15 | self.inner.commit(&section, &transaction).unwrap(); | |
| 16 | } | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | impl Storage for Racing { | |
| 21 | fn discover( | |
| 22 | &self, | |
| 23 | cache: &mut discover::Cache, | |
| 24 | limits: discover::Limits, | |
| 25 | ) -> Result<discover::Folder> { | |
| 26 | self.inner.discover(cache, limits) | |
| 27 | } | |
| 28 | fn location(&self) -> String { | |
| 29 | self.inner.location() | |
| 30 | } | |
| 31 | fn exists(&self, path: &str) -> bool { | |
| 32 | self.inner.exists(path) | |
| 33 | } | |
| 34 | fn read(&self, path: &str) -> Result<Vec<u8>> { | |
| 35 | self.inner.read(path) | |
| 36 | } | |
| 37 | fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> { | |
| 38 | self.inner.read_file(path, limit) | |
| 39 | } | |
| 40 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { | |
| 41 | self.inner.create(path, bytes) | |
| 42 | } | |
| 43 | fn create_directory(&self, path: &str) -> Result<()> { | |
| 44 | self.inner.create_directory(path) | |
| 45 | } | |
| 46 | fn hide(&self, path: &str) -> Result<()> { | |
| 47 | self.inner.hide(path) | |
| 48 | } | |
| 49 | fn rename(&self, from: &str, to: &str) -> Result<()> { | |
| 50 | self.inner.rename(from, to) | |
| 51 | } | |
| 52 | fn replace(&self, from: &str, to: &str) -> Result<()> { | |
| 53 | self.inner.replace(from, to) | |
| 54 | } | |
| 55 | fn delete(&self, path: &str) -> Result<()> { | |
| 56 | self.inner.delete(path) | |
| 57 | } | |
| 58 | fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { | |
| 59 | self.inner.place(path, ancestor, name) | |
| 60 | } | |
| 61 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { | |
| 62 | self.inner.commit(path, transaction) | |
| 63 | } | |
| 64 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { | |
| 65 | self.race(path); | |
| 66 | self.inner.supersede(path, base, with) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /// A revision another writer commits while a password is being set is never overwritten: | |
| 71 | /// the section keeps it, and the password waits for a later try. | |
| 72 | #[test] | |
| 73 | fn a_password_never_overwrites_a_concurrent_revision() { | |
| 74 | let temporary = tempfile::tempdir().unwrap(); | |
| 75 | let folder = temporary.path().join("notebook"); | |
| 76 | let cache = temporary.path().join("cache"); | |
| 77 | let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap(); | |
| 78 | let path = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation) | |
| 79 | .unwrap() | |
| 80 | .catalog() | |
| 81 | .sections[0] | |
| 82 | .path | |
| 83 | .clone(); | |
| 84 | let file = folder.canonicalize().unwrap().join(&path); | |
| 85 | let image = std::fs::read(&file).unwrap(); | |
| 86 | let arena = onestore::Arena::default(); | |
| 87 | let mut section = onestore::Section::open(&arena, image).unwrap(); | |
| 88 | let theirs = PageCreation::new(None, Some("Theirs"), "OneNote").unwrap(); | |
| 89 | section | |
| 90 | .apply( | |
| 91 | "OneNote", | |
| 92 | &Edit { | |
| 93 | at: crate::now(), | |
| 94 | ops: vec![Op::Section(SectionOp::Create(theirs))], | |
| 95 | }, | |
| 96 | ) | |
| 97 | .unwrap(); | |
| 98 | let revision = section.seal().unwrap().unwrap(); | |
| 99 | ||
| 100 | let root = folder.canonicalize().unwrap(); | |
| 101 | let storage = Racing { | |
| 102 | inner: Directory(root.clone()), | |
| 103 | revision: Mutex::new(Some((path.clone(), revision))), | |
| 104 | }; | |
| 105 | let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); | |
| 106 | let set = notebook.set_password(&path, None, Some("secret")); | |
| 107 | let titles = |image: &[u8]| -> Vec<String> { | |
| 108 | stored_pages(image) | |
| 109 | .unwrap() | |
| 110 | .into_iter() | |
| 111 | .map(|page| page.page.title) | |
| 112 | .collect() | |
| 113 | }; | |
| 114 | let stored = std::fs::read(&file).unwrap(); | |
| 115 | match set { | |
| 116 | Ok(key) => { | |
| 117 | let key = key.unwrap(); | |
| 118 | let pages = stored_pages_unlocked(&stored, &key).unwrap(); | |
| 119 | assert!(pages.iter().any(|page| page.page.title == "Theirs")); | |
| 120 | } | |
| 121 | Err(_) => assert_eq!(titles(&stored), ["Mine", "Theirs"]), | |
| 122 | } | |
| 123 | } |
crates/notebook/src/sidecar/tests.rs+4| ... | ... | @@ -165,6 +165,10 @@ impl Storage for Folder { |
| 165 | 165 | fn commit(&self, _: &str, _: &Transaction) -> Result<()> { |
| 166 | 166 | unimplemented!() |
| 167 | 167 | } |
| 168 | ||
| 169 | fn supersede(&self, _: &str, _: &onestore::Stamp, _: &str) -> Result<()> { | |
| 170 | unimplemented!() | |
| 171 | } | |
| 168 | 172 | } |
| 169 | 173 | |
| 170 | 174 | #[test] |
crates/notebook/src/smb/mod.rs+29-2| ... | ... | @@ -331,7 +331,7 @@ impl Client { |
| 331 | 331 | |
| 332 | 332 | /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 |
| 333 | 333 | /// skips a hidden folder. |
| 334 | pub fn hide(&self, path: &str) -> io::Result<()> { | |
| 334 | pub(crate) fn hide(&self, path: &str) -> io::Result<()> { | |
| 335 | 335 | // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES. |
| 336 | 336 | let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?; |
| 337 | 337 | let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?; |
| ... | ... | @@ -389,7 +389,7 @@ impl Client { |
| 389 | 389 | |
| 390 | 390 | /// Reads a bounded external payload while denying concurrent writes and deletion. |
| 391 | 391 | /// Empty files succeed; limits, sharing contention and failed close return no payload. |
| 392 | pub fn read_asset(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | |
| 392 | pub(crate) fn read_asset(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | |
| 393 | 393 | let mut file = self.open_shared(path, false, 1)?; |
| 394 | 394 | let mut bytes = Vec::new(); |
| 395 | 395 | let mut block = [0; 65536]; |
| ... | ... | @@ -493,6 +493,33 @@ impl Client { |
| 493 | 493 | self.commit(path, &checked(base), |file| onestore::confirm(file, base)) |
| 494 | 494 | } |
| 495 | 495 | |
| 496 | /// Puts the file `with` in the place of the revision store at `path`, provided `path` still | |
| 497 | /// has `base`'s stamp, as OneNote 2010's maintenance puts a file it wrote anew in place: | |
| 498 | /// under writer coordination the old file goes aside, `with` takes its name, and the old | |
| 499 | /// file is deleted once released. A server renames nothing over an open file. | |
| 500 | pub(crate) fn supersede( | |
| 501 | &self, | |
| 502 | path: &str, | |
| 503 | base: &onestore::Stamp, | |
| 504 | with: &str, | |
| 505 | ) -> Result<(), CommitError> { | |
| 506 | let aside = format!("{with}.old"); | |
| 507 | let failed = |state| move |error| CommitError { state, error }; | |
| 508 | self.commit(path, &checked(base), |file| { | |
| 509 | base.check(file) | |
| 510 | .and_then(|()| self.rename(path, &aside)) | |
| 511 | .map_err(failed(CommitState::NotCommitted))?; | |
| 512 | self.rename(with, path).map_err(|error| CommitError { | |
| 513 | state: match self.rename(&aside, path) { | |
| 514 | Ok(()) => CommitState::NotCommitted, | |
| 515 | Err(_) => CommitState::Unknown, | |
| 516 | }, | |
| 517 | error, | |
| 518 | }) | |
| 519 | })?; | |
| 520 | self.delete(&aside).map_err(failed(CommitState::Committed)) | |
| 521 | } | |
| 522 | ||
| 496 | 523 | /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks. |
| 497 | 524 | fn commit( |
| 498 | 525 | &self, |
crates/notebook/src/smb/tests.rs+47-4| ... | ... | @@ -463,9 +463,16 @@ fn live_watch() { |
| 463 | 463 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] |
| 464 | 464 | fn live_storage_inspection() { |
| 465 | 465 | let reader = client(); |
| 466 | for (index, fixture) in [ | |
| 467 | "native-encrypted/encrypted-01/notebook/synthetic.one", | |
| 468 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | |
| 466 | // Stably unreadable, never contention: a protected section, and a file too damaged to say. | |
| 467 | for (index, (fixture, unreadable)) in [ | |
| 468 | ( | |
| 469 | "native-encrypted/encrypted-01/notebook/synthetic.one", | |
| 470 | io::ErrorKind::Unsupported, | |
| 471 | ), | |
| 472 | ( | |
| 473 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | |
| 474 | io::ErrorKind::InvalidData, | |
| 475 | ), | |
| 469 | 476 | ] |
| 470 | 477 | .into_iter() |
| 471 | 478 | .enumerate() |
| ... | ... | @@ -482,7 +489,7 @@ fn live_storage_inspection() { |
| 482 | 489 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); |
| 483 | 490 | assert_eq!( |
| 484 | 491 | reader.read(&path, source.len()).unwrap_err().kind(), |
| 485 | io::ErrorKind::WouldBlock | |
| 492 | unreadable | |
| 486 | 493 | ); |
| 487 | 494 | let maintenance = client(); |
| 488 | 495 | let guard = maintenance.open(&path, false).unwrap(); |
| ... | ... | @@ -777,6 +784,42 @@ fn live_structure() { |
| 777 | 784 | assert_eq!(header.ancestor, toc_id); |
| 778 | 785 | assert_eq!(header.name_crc, 0x6108912a); |
| 779 | 786 | assert!(fresh.section("Renamed.one", || {}).is_err()); |
| 787 | // A password supersedes the file under writer coordination, never over a newer stamp, and | |
| 788 | // while a reader holds it open, as OneNote's readers share it with deletion. | |
| 789 | let section = format!("{root}/Renamed.one"); | |
| 790 | let written = format!("{root}/.Renamed.one.snowbound"); | |
| 791 | client.create(&written, &renamed).unwrap(); | |
| 792 | let mut stale = onestore::Stamp::of(&renamed).unwrap(); | |
| 793 | stale.length += 1; | |
| 794 | let refused = client.supersede(&section, &stale, &written).unwrap_err(); | |
| 795 | assert_eq!(refused.state, CommitState::NotCommitted); | |
| 796 | assert_eq!(client.read_storage(&section, 1 << 20).unwrap(), renamed); | |
| 797 | client.delete(&written).unwrap(); | |
| 798 | let mut fresh = fresh; | |
| 799 | let reader = client.open(&section, false).unwrap(); | |
| 800 | let key = fresh | |
| 801 | .set_password("Renamed.one", None, Some("secret")) | |
| 802 | .unwrap() | |
| 803 | .unwrap(); | |
| 804 | reader.close().unwrap(); | |
| 805 | let protected = client.read_storage(&section, 1 << 20).unwrap(); | |
| 806 | assert_eq!( | |
| 807 | onestore::protected::Key::open(&protected, "secret") | |
| 808 | .unwrap() | |
| 809 | .secret(), | |
| 810 | key.secret() | |
| 811 | ); | |
| 812 | assert!(matches!( | |
| 813 | fresh.catalog().sections[0].state, | |
| 814 | crate::discover::SectionState::Locked | |
| 815 | )); | |
| 816 | let names: Vec<String> = client | |
| 817 | .read_dir(&root, 100) | |
| 818 | .unwrap() | |
| 819 | .into_iter() | |
| 820 | .map(|entry| entry.name) | |
| 821 | .collect(); | |
| 822 | assert!(!names.iter().any(|name| name.starts_with('.')), "{names:?}"); | |
| 780 | 823 | for path in [ |
| 781 | 824 | "Renamed.one", |
| 782 | 825 | "Kept/Inner.one", |
crates/notebook/src/sync.rs+32-28| ... | ... | @@ -84,8 +84,7 @@ struct Blocked { |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | fn state(connection: &Connection) -> Result<State> { |
| 87 | let base = base::stamp(connection, base::Image::Base)? | |
| 88 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | |
| 87 | let base = base::base_stamp(connection)?; | |
| 89 | 88 | let blocked = connection |
| 90 | 89 | .query_row( |
| 91 | 90 | "SELECT id, revisions FROM batches WHERE attempted=1 ORDER BY id LIMIT 1", |
| ... | ... | @@ -158,20 +157,17 @@ fn newest(connection: &Connection, batch: i64) -> Result<u64> { |
| 158 | 157 | impl Replica { |
| 159 | 158 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. |
| 160 | 159 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { |
| 161 | status(&*self.lock()?, id) | |
| 160 | status(&*self.lock()?, id, self.section.key.as_ref()) | |
| 162 | 161 | } |
| 163 | 162 | |
| 164 | 163 | /// The last observed remote image. Observation alone does not acknowledge any pending |
| 165 | 164 | /// edit's remote durability. |
| 166 | 165 | pub(crate) fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 167 | 166 | let connection = self.lock()?; |
| 168 | let image = match base::read(&connection, base::Image::Remote)? { | |
| 169 | Some(image) => Some(image), | |
| 170 | None => base::read(&connection, base::Image::Base)?, | |
| 171 | }; | |
| 172 | Ok(image.ok_or_else(|| { | |
| 173 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | |
| 174 | })?) | |
| 167 | match base::read(&connection, base::Image::Remote)? { | |
| 168 | Some(image) => Ok(image), | |
| 169 | None => base::base(&connection), | |
| 170 | } | |
| 175 | 171 | } |
| 176 | 172 | |
| 177 | 173 | /// Publishes the oldest unpublished batch, rebasing the queue first when the remote |
| ... | ... | @@ -208,7 +204,8 @@ impl Replica { |
| 208 | 204 | return Ok(Synced { |
| 209 | 205 | edit: Some(( |
| 210 | 206 | id, |
| 211 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | |
| 207 | status(&*self.lock()?, id, self.section.key.as_ref())? | |
| 208 | .unwrap_or(EditStatus::Pending), | |
| 212 | 209 | )), |
| 213 | 210 | changed: Vec::new(), |
| 214 | 211 | }); |
| ... | ... | @@ -217,9 +214,15 @@ impl Replica { |
| 217 | 214 | observed if observed == state.base => None, |
| 218 | 215 | _ => { |
| 219 | 216 | let image = remote.read().map_err(Error::RemoteIo)?; |
| 217 | // Protected elsewhere: a section written anew, which only its key reads. | |
| 218 | if self.section.key.is_none() && crate::discover::locked(&Store::parse(&image)?) { | |
| 219 | return Err(Error::RemoteIo(io::Error::new( | |
| 220 | io::ErrorKind::Unsupported, | |
| 221 | "Password protected", | |
| 222 | ))); | |
| 223 | } | |
| 220 | 224 | // A read image is compared whole: a stamp stands for it only when read alone. |
| 221 | let base = base::read(&*self.lock()?, base::Image::Base)?; | |
| 222 | (base.as_deref() != Some(&image[..])).then_some(image) | |
| 225 | (base::base(&*self.lock()?)? != image).then_some(image) | |
| 223 | 226 | } |
| 224 | 227 | }; |
| 225 | 228 | let mut changed = Vec::new(); |
| ... | ... | @@ -265,7 +268,8 @@ impl Replica { |
| 265 | 268 | return Ok(Synced { |
| 266 | 269 | edit: Some(( |
| 267 | 270 | id, |
| 268 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | |
| 271 | status(&*self.lock()?, id, self.section.key.as_ref())? | |
| 272 | .unwrap_or(EditStatus::Pending), | |
| 269 | 273 | )), |
| 270 | 274 | changed, |
| 271 | 275 | }); |
| ... | ... | @@ -301,7 +305,8 @@ impl Replica { |
| 301 | 305 | return Ok(Synced { |
| 302 | 306 | edit: Some(( |
| 303 | 307 | id, |
| 304 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | |
| 308 | status(&*self.lock()?, id, self.section.key.as_ref())? | |
| 309 | .unwrap_or(EditStatus::Pending), | |
| 305 | 310 | )), |
| 306 | 311 | changed, |
| 307 | 312 | }); |
| ... | ... | @@ -329,9 +334,7 @@ impl Replica { |
| 329 | 334 | let id = newest(&*self.lock()?, batch)?; |
| 330 | 335 | let Some(transaction) = transaction else { |
| 331 | 336 | // Edits that changed nothing are published once the remote's image is durable. |
| 332 | let base = base::stamp(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { | |
| 333 | io::Error::new(io::ErrorKind::InvalidData, "The cache holds no base image") | |
| 334 | })?; | |
| 337 | let base = base::base_stamp(&*self.lock()?)?; | |
| 335 | 338 | if let Err(error) = remote.confirm(&base) { |
| 336 | 339 | if error.state == CommitState::Committed { |
| 337 | 340 | self.acknowledge(batch, None, None)?; |
| ... | ... | @@ -373,15 +376,13 @@ impl Replica { |
| 373 | 376 | sealed: Option<&Transaction>, |
| 374 | 377 | revisions: &BTreeMap<ExGuid, ExGuid>, |
| 375 | 378 | ) -> Result<bool> { |
| 376 | let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { | |
| 377 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | |
| 378 | })?; | |
| 379 | let base = base::base(&*self.lock()?)?; | |
| 379 | 380 | let (local, remote) = (onestore::Arena::default(), onestore::Arena::default()); |
| 380 | let mut local = onestore::Section::open(&local, base)?; | |
| 381 | let mut local = working::open(&local, base, self.section.key.as_ref())?; | |
| 381 | 382 | if let Some(sealed) = sealed { |
| 382 | 383 | local.replay(sealed)?; |
| 383 | 384 | } |
| 384 | let mut remote = onestore::Section::open(&remote, image.to_vec())?; | |
| 385 | let mut remote = working::open(&remote, image.to_vec(), self.section.key.as_ref())?; | |
| 385 | 386 | // A page's versions change without its page changing. |
| 386 | 387 | let versions = |section: &mut onestore::Section<'_>| { |
| 387 | 388 | section.versions().map(|versions| { |
| ... | ... | @@ -403,7 +404,7 @@ impl Replica { |
| 403 | 404 | } |
| 404 | 405 | |
| 405 | 406 | fn receipt(&self, id: u64) -> Result<ExGuid> { |
| 406 | match status(&*self.lock()?, id)? { | |
| 407 | match status(&*self.lock()?, id, self.section.key.as_ref())? { | |
| 407 | 408 | Some(EditStatus::Published { revision }) => Ok(revision), |
| 408 | 409 | _ => Err(io::Error::other("A published edit has no receipt").into()), |
| 409 | 410 | } |
| ... | ... | @@ -431,7 +432,7 @@ impl Replica { |
| 431 | 432 | |row| row.get::<_, String>(0), |
| 432 | 433 | )?)?, |
| 433 | 434 | }; |
| 434 | for queued in queue::load(&database, Some(batch))? { | |
| 435 | for queued in queue::load(&database, self.section.key.as_ref(), Some(batch))? { | |
| 435 | 436 | database.execute( |
| 436 | 437 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", |
| 437 | 438 | params![ |
| ... | ... | @@ -527,7 +528,11 @@ impl Replica { |
| 527 | 528 | } |
| 528 | 529 | } |
| 529 | 530 | |
| 530 | pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> { | |
| 531 | pub(crate) fn status( | |
| 532 | connection: &Connection, | |
| 533 | id: u64, | |
| 534 | key: Option<&onestore::protected::Key>, | |
| 535 | ) -> Result<Option<EditStatus>> { | |
| 531 | 536 | let id = signed(id)?; |
| 532 | 537 | if let Some(revision) = connection |
| 533 | 538 | .query_row( |
| ... | ... | @@ -563,8 +568,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 563 | 568 | None => None, |
| 564 | 569 | Some((true, revisions, edit)) => { |
| 565 | 570 | let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; |
| 566 | let edit: onestore::op::Edit = serde_json::from_str(&edit) | |
| 567 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | |
| 571 | let edit = queue::parse(key, &edit)?; | |
| 568 | 572 | let revision = revision_of(&edit, &revisions)?; |
| 569 | 573 | Some(EditStatus::AwaitingConfirmation { revision }) |
| 570 | 574 | } |
crates/notebook/src/worker.rs+1| ... | ... | @@ -166,6 +166,7 @@ impl Replica { |
| 166 | 166 | )); |
| 167 | 167 | } |
| 168 | 168 | let mut owner = self |
| 169 | .section | |
| 169 | 170 | .worker |
| 170 | 171 | .lock() |
| 171 | 172 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; |
crates/notebook/src/working.rs+63-56| ... | ... | @@ -8,6 +8,7 @@ use onestore::{ |
| 8 | 8 | Arena, ExGuid, Section, Transaction, |
| 9 | 9 | op::{Edit, OpError}, |
| 10 | 10 | page::Page, |
| 11 | protected::Key, | |
| 11 | 12 | }; |
| 12 | 13 | use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; |
| 13 | 14 | use std::{ |
| ... | ... | @@ -96,18 +97,19 @@ impl Request { |
| 96 | 97 | } |
| 97 | 98 | } |
| 98 | 99 | |
| 99 | type Worker = Arc<Mutex<Weak<Signal>>>; | |
| 100 | ||
| 101 | 100 | /// The section thread as the replica sees it. Rereading the section after the queue was |
| 102 | 101 | /// replaced (a rebase, a released attempt) happens on a new thread while the current one |
| 103 | 102 | /// keeps answering page reads from the section as it was; the new thread then takes the |
| 104 | 103 | /// requests over, so reads never wait for a rebuild. |
| 105 | 104 | pub(crate) struct Thread { |
| 106 | connection: Arc<Mutex<Connection>>, | |
| 107 | worker: Worker, | |
| 105 | pub(crate) connection: Mutex<Connection>, | |
| 106 | /// The sync worker, which a durable burst of edits wakes. | |
| 107 | pub(crate) worker: Mutex<Weak<Signal>>, | |
| 108 | 108 | /// Taken when the replica drops, which ends every section thread. |
| 109 | 109 | sender: Mutex<Option<mpsc::Sender<Request>>>, |
| 110 | 110 | threads: Mutex<Vec<thread::JoinHandle<()>>>, |
| 111 | /// A password-protected section's key, under which its queue is sealed too. | |
| 112 | pub(crate) key: Option<Key>, | |
| 111 | 113 | } |
| 112 | 114 | |
| 113 | 115 | impl Thread { |
| ... | ... | @@ -148,16 +150,14 @@ impl Thread { |
| 148 | 150 | } |
| 149 | 151 | |
| 150 | 152 | /// Starts the section thread once the queue opens. |
| 151 | pub(crate) fn spawn( | |
| 152 | connection: Arc<Mutex<Connection>>, | |
| 153 | worker: Worker, | |
| 154 | ) -> Result<(Arc<Thread>, ExGuid)> { | |
| 153 | pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thread>, ExGuid)> { | |
| 155 | 154 | let (sender, requests) = mpsc::channel(); |
| 156 | 155 | let shared = Arc::new(Thread { |
| 157 | connection, | |
| 158 | worker, | |
| 156 | connection: Mutex::new(connection), | |
| 157 | worker: Mutex::new(Weak::new()), | |
| 159 | 158 | sender: Mutex::new(Some(sender)), |
| 160 | 159 | threads: Mutex::new(Vec::new()), |
| 160 | key, | |
| 161 | 161 | }); |
| 162 | 162 | let (ready, opened) = mpsc::sync_channel(1); |
| 163 | 163 | shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; |
| ... | ... | @@ -188,7 +188,7 @@ fn run( |
| 188 | 188 | ) { |
| 189 | 189 | loop { |
| 190 | 190 | let arena = Arena::default(); |
| 191 | let working = match Working::open(&arena, &shared.connection) { | |
| 191 | let working = match Working::open(&arena, &shared.connection, shared.key.as_ref()) { | |
| 192 | 192 | Ok(working) => working, |
| 193 | 193 | Err(error) => { |
| 194 | 194 | if let Some(ready) = ready.take() { |
| ... | ... | @@ -237,17 +237,19 @@ enum Job { |
| 237 | 237 | fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) { |
| 238 | 238 | let answer: Box<dyn FnOnce() + Send> = match job { |
| 239 | 239 | Job::Reopen { reply } => Box::new(move || reply(Ok(()))), |
| 240 | Job::Rebase { image, reply } => match rebase(&shared.connection, image) { | |
| 241 | Ok(changed) => Box::new(move || reply(Ok(changed))), | |
| 242 | Err(error) => { | |
| 243 | reply(Err(error)); | |
| 244 | let _ = signal.send(Request::Resume); | |
| 245 | return; | |
| 240 | Job::Rebase { image, reply } => { | |
| 241 | match rebase(&shared.connection, image, shared.key.as_ref()) { | |
| 242 | Ok(changed) => Box::new(move || reply(Ok(changed))), | |
| 243 | Err(error) => { | |
| 244 | reply(Err(error)); | |
| 245 | let _ = signal.send(Request::Resume); | |
| 246 | return; | |
| 247 | } | |
| 246 | 248 | } |
| 247 | }, | |
| 249 | } | |
| 248 | 250 | }; |
| 249 | 251 | let arena = Arena::default(); |
| 250 | let opened = Working::open(&arena, &shared.connection); | |
| 252 | let opened = Working::open(&arena, &shared.connection, shared.key.as_ref()); | |
| 251 | 253 | let (to, from) = mpsc::sync_channel(1); |
| 252 | 254 | if signal.send(Request::Handover(to)).is_err() { |
| 253 | 255 | return; |
| ... | ... | @@ -286,8 +288,8 @@ struct Working<'a> { |
| 286 | 288 | } |
| 287 | 289 | |
| 288 | 290 | impl<'a> Working<'a> { |
| 289 | fn open(arena: &'a Arena, connection: &Mutex<Connection>) -> Result<Self> { | |
| 290 | let (section, open, touched) = replay(arena, &*lock(connection)?)?; | |
| 291 | fn open(arena: &'a Arena, connection: &Mutex<Connection>, key: Option<&Key>) -> Result<Self> { | |
| 292 | let (section, open, touched) = replay(arena, &*lock(connection)?, key)?; | |
| 291 | 293 | Ok(Self { |
| 292 | 294 | section, |
| 293 | 295 | open, |
| ... | ... | @@ -301,7 +303,7 @@ impl<'a> Working<'a> { |
| 301 | 303 | requests: &mpsc::Receiver<Request>, |
| 302 | 304 | backlog: &mut VecDeque<Request>, |
| 303 | 305 | ) -> Next { |
| 304 | let (connection, worker) = (&*shared.connection, &shared.worker); | |
| 306 | let connection = &shared.connection; | |
| 305 | 307 | // While another thread rebuilds the section, reads answer from this one and every |
| 306 | 308 | // other request waits for the rebuilt section. |
| 307 | 309 | let mut held: Option<VecDeque<Request>> = None; |
| ... | ... | @@ -356,7 +358,7 @@ impl<'a> Working<'a> { |
| 356 | 358 | edit, |
| 357 | 359 | reply, |
| 358 | 360 | } => self |
| 359 | .accept(author, edit, reply, &mut accepted, connection, worker) | |
| 361 | .accept(author, edit, reply, &mut accepted, shared) | |
| 360 | 362 | .err() |
| 361 | 363 | .unwrap_or(false), |
| 362 | 364 | Request::Page { space, reply } => { |
| ... | ... | @@ -390,7 +392,7 @@ impl<'a> Working<'a> { |
| 390 | 392 | false |
| 391 | 393 | } |
| 392 | 394 | Request::Flush { reply } => { |
| 393 | let written = self.flush(connection, worker, &mut accepted); | |
| 395 | let written = self.flush(shared, &mut accepted); | |
| 394 | 396 | reply(if written { |
| 395 | 397 | Ok(()) |
| 396 | 398 | } else { |
| ... | ... | @@ -399,7 +401,7 @@ impl<'a> Working<'a> { |
| 399 | 401 | !written |
| 400 | 402 | } |
| 401 | 403 | Request::Seal { reply } => { |
| 402 | if !self.flush(connection, worker, &mut accepted) { | |
| 404 | if !self.flush(shared, &mut accepted) { | |
| 403 | 405 | reply(Err( |
| 404 | 406 | io::Error::other("The queue could not be written").into() |
| 405 | 407 | )); |
| ... | ... | @@ -412,7 +414,7 @@ impl<'a> Working<'a> { |
| 412 | 414 | } |
| 413 | 415 | } |
| 414 | 416 | Request::Rebase { image, reply } => { |
| 415 | if self.flush(connection, worker, &mut accepted) { | |
| 417 | if self.flush(shared, &mut accepted) { | |
| 416 | 418 | held = self |
| 417 | 419 | .rebuild(shared, Job::Rebase { image, reply }) |
| 418 | 420 | .then(VecDeque::new); |
| ... | ... | @@ -425,7 +427,7 @@ impl<'a> Working<'a> { |
| 425 | 427 | } |
| 426 | 428 | } |
| 427 | 429 | Request::Reopen { reply } => { |
| 428 | self.flush(connection, worker, &mut accepted); | |
| 430 | self.flush(shared, &mut accepted); | |
| 429 | 431 | held = self |
| 430 | 432 | .rebuild(shared, Job::Reopen { reply }) |
| 431 | 433 | .then(VecDeque::new); |
| ... | ... | @@ -438,7 +440,7 @@ impl<'a> Working<'a> { |
| 438 | 440 | return Next::Reopen; |
| 439 | 441 | } |
| 440 | 442 | } |
| 441 | if !self.flush(connection, worker, &mut accepted) { | |
| 443 | if !self.flush(shared, &mut accepted) { | |
| 442 | 444 | return Next::Reopen; |
| 443 | 445 | } |
| 444 | 446 | } |
| ... | ... | @@ -476,8 +478,7 @@ impl<'a> Working<'a> { |
| 476 | 478 | edit: Edit, |
| 477 | 479 | reply: Reply<u64>, |
| 478 | 480 | accepted: &mut Vec<Accepted>, |
| 479 | connection: &Mutex<Connection>, | |
| 480 | worker: &Worker, | |
| 481 | shared: &Thread, | |
| 481 | 482 | ) -> std::result::Result<(), bool> { |
| 482 | 483 | match self.section.apply(&author, &edit) { |
| 483 | 484 | Ok(()) => { |
| ... | ... | @@ -493,7 +494,7 @@ impl<'a> Working<'a> { |
| 493 | 494 | Err(error) => { |
| 494 | 495 | let broken = matches!(error, OpError::Failed(_)); |
| 495 | 496 | if broken { |
| 496 | self.flush(connection, worker, accepted); | |
| 497 | self.flush(shared, accepted); | |
| 497 | 498 | } |
| 498 | 499 | reply(Err(error.into())); |
| 499 | 500 | Err(broken) |
| ... | ... | @@ -503,17 +504,12 @@ impl<'a> Working<'a> { |
| 503 | 504 | |
| 504 | 505 | /// Writes the accepted edits in one transaction, then answers their senders; false |
| 505 | 506 | /// when the write failed and the section holds edits the queue lacks. |
| 506 | fn flush( | |
| 507 | &mut self, | |
| 508 | connection: &Mutex<Connection>, | |
| 509 | worker: &Worker, | |
| 510 | accepted: &mut Vec<Accepted>, | |
| 511 | ) -> bool { | |
| 507 | fn flush(&mut self, shared: &Thread, accepted: &mut Vec<Accepted>) -> bool { | |
| 512 | 508 | if accepted.is_empty() { |
| 513 | 509 | return true; |
| 514 | 510 | } |
| 515 | 511 | let written = (|| -> Result<Vec<u64>> { |
| 516 | let mut connection = lock(connection)?; | |
| 512 | let mut connection = lock(&shared.connection)?; | |
| 517 | 513 | let transaction = |
| 518 | 514 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 519 | 515 | let batch = match self.open { |
| ... | ... | @@ -527,6 +523,7 @@ impl<'a> Working<'a> { |
| 527 | 523 | for edit in accepted.iter() { |
| 528 | 524 | ids.push(queue::insert( |
| 529 | 525 | &transaction, |
| 526 | shared.key.as_ref(), | |
| 530 | 527 | None, |
| 531 | 528 | batch, |
| 532 | 529 | &edit.author, |
| ... | ... | @@ -543,7 +540,7 @@ impl<'a> Working<'a> { |
| 543 | 540 | for (edit, id) in accepted.drain(..).zip(ids) { |
| 544 | 541 | (edit.reply)(Ok(id)); |
| 545 | 542 | } |
| 546 | crate::edited(worker); | |
| 543 | crate::edited(&shared.worker); | |
| 547 | 544 | } |
| 548 | 545 | Err(error) => { |
| 549 | 546 | let message = error.to_string(); |
| ... | ... | @@ -600,7 +597,11 @@ impl<'a> Working<'a> { |
| 600 | 597 | /// Replays every queued edit on `image`, which becomes the base; each page whose local |
| 601 | 598 | /// version the remote could not take gains a conflict page holding it, queued as a new edit. |
| 602 | 599 | /// Returns the pages the remote changed. |
| 603 | fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<ExGuid>> { | |
| 600 | fn rebase( | |
| 601 | connection: &Mutex<Connection>, | |
| 602 | image: Option<Vec<u8>>, | |
| 603 | key: Option<&Key>, | |
| 604 | ) -> Result<Vec<ExGuid>> { | |
| 604 | 605 | let (base_image, image, edits) = { |
| 605 | 606 | let connection = lock(connection)?; |
| 606 | 607 | if connection.query_row( |
| ... | ... | @@ -614,22 +615,20 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 614 | 615 | ) |
| 615 | 616 | .into()); |
| 616 | 617 | } |
| 617 | let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { | |
| 618 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | |
| 619 | })?; | |
| 618 | let base_image = base::base(&connection)?; | |
| 620 | 619 | let image = match image { |
| 621 | 620 | Some(image) => image, |
| 622 | 621 | None => { |
| 623 | 622 | base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone()) |
| 624 | 623 | } |
| 625 | 624 | }; |
| 626 | (base_image, image, queue::load(&connection, None)?) | |
| 625 | (base_image, image, queue::load(&connection, key, None)?) | |
| 627 | 626 | }; |
| 628 | 627 | let old_arena = Arena::default(); |
| 629 | let mut old = Section::open(&old_arena, base_image)?; | |
| 628 | let mut old = open(&old_arena, base_image, key)?; | |
| 630 | 629 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); |
| 631 | 630 | let remote_arena = Arena::default(); |
| 632 | let remote = Section::open(&remote_arena, image.clone())?; | |
| 631 | let remote = open(&remote_arena, image.clone(), key)?; | |
| 633 | 632 | if old.root() != remote.root() { |
| 634 | 633 | return Err(io::Error::new( |
| 635 | 634 | io::ErrorKind::InvalidInput, |
| ... | ... | @@ -645,10 +644,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 645 | 644 | let mut converged = BTreeSet::new(); |
| 646 | 645 | let (rewritten, added) = loop { |
| 647 | 646 | let arena = Arena::default(); |
| 648 | let mut new = Section::open(&arena, image.clone())?; | |
| 647 | let mut new = open(&arena, image.clone(), key)?; | |
| 649 | 648 | let merged = merge::rebase(&mut old, &mut new, &edits, &converged)?; |
| 650 | 649 | if !merged.conflicts.is_empty() && local.is_none() { |
| 651 | local = Some(replay(&local_arena, &*lock(connection)?)?.0); | |
| 650 | local = Some(replay(&local_arena, &*lock(connection)?, key)?.0); | |
| 652 | 651 | } |
| 653 | 652 | let pages: BTreeMap<ExGuid, Page> = merged |
| 654 | 653 | .conflicts |
| ... | ... | @@ -694,10 +693,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 694 | 693 | transaction.execute("UPDATE edits SET batch=?1", [batch])?; |
| 695 | 694 | transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; |
| 696 | 695 | for (id, edit) in rewritten { |
| 697 | queue::rewrite(&transaction, id, &edit)?; | |
| 696 | queue::rewrite(&transaction, key, id, &edit)?; | |
| 698 | 697 | } |
| 699 | 698 | for (author, edit) in &added { |
| 700 | queue::insert(&transaction, None, batch, author, edit)?; | |
| 699 | queue::insert(&transaction, key, None, batch, author, edit)?; | |
| 701 | 700 | } |
| 702 | 701 | if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 { |
| 703 | 702 | transaction.execute("DELETE FROM batches", [])?; |
| ... | ... | @@ -743,10 +742,10 @@ fn batches(connection: &Connection) -> Result<Vec<(i64, Option<Option<Transactio |
| 743 | 742 | fn replay<'a>( |
| 744 | 743 | arena: &'a Arena, |
| 745 | 744 | connection: &Connection, |
| 745 | key: Option<&Key>, | |
| 746 | 746 | ) -> Result<(Section<'a>, Option<i64>, BTreeSet<ExGuid>)> { |
| 747 | let image = base::read(connection, base::Image::Base)? | |
| 748 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | |
| 749 | let mut section = Section::open(arena, image)?; | |
| 747 | let image = base::base(connection)?; | |
| 748 | let mut section = open(arena, image, key)?; | |
| 750 | 749 | let mut open = None; |
| 751 | 750 | let mut touched = BTreeSet::new(); |
| 752 | 751 | for (batch, sealed) in batches(connection)? { |
| ... | ... | @@ -761,7 +760,7 @@ fn replay<'a>( |
| 761 | 760 | Some(Some(transaction)) => section.replay(&transaction)?, |
| 762 | 761 | Some(None) => {} |
| 763 | 762 | None => { |
| 764 | for queued in queue::load(connection, Some(batch))? { | |
| 763 | for queued in queue::load(connection, key, Some(batch))? { | |
| 765 | 764 | section |
| 766 | 765 | .apply(&queued.author, &queued.edit) |
| 767 | 766 | .map_err(|error| { |
| ... | ... | @@ -781,13 +780,21 @@ fn replay<'a>( |
| 781 | 780 | |
| 782 | 781 | /// The image the queue leaves, its unsealed edits sealed as one more revision; that |
| 783 | 782 | /// revision's identities are fresh on every call. O(section), for tests and recovery. |
| 784 | pub(crate) fn image(connection: &Connection) -> Result<Vec<u8>> { | |
| 783 | pub(crate) fn image(connection: &Connection, key: Option<&Key>) -> Result<Vec<u8>> { | |
| 785 | 784 | let arena = Arena::default(); |
| 786 | let (mut section, ..) = replay(&arena, connection)?; | |
| 785 | let (mut section, ..) = replay(&arena, connection, key)?; | |
| 787 | 786 | section.seal()?; |
| 788 | 787 | Ok(section.image()) |
| 789 | 788 | } |
| 790 | 789 | |
| 790 | /// A section image, a password-protected one under `key`. | |
| 791 | pub(crate) fn open<'a>(arena: &'a Arena, image: Vec<u8>, key: Option<&Key>) -> Result<Section<'a>> { | |
| 792 | Ok(match key { | |
| 793 | Some(key) => Section::unlock(arena, image, key)?, | |
| 794 | None => Section::open(arena, image)?, | |
| 795 | }) | |
| 796 | } | |
| 797 | ||
| 791 | 798 | /// The sealed batch waiting for publication, if any. |
| 792 | 799 | pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> { |
| 793 | 800 | let row: Option<(i64, String)> = connection |
crates/notebook/tests/cache.rs+2-2| ... | ... | @@ -66,14 +66,14 @@ fn a_cache_created_meanwhile_opens() { |
| 66 | 66 | let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap(); |
| 67 | 67 | let directory = tempfile::tempdir().unwrap(); |
| 68 | 68 | let path = directory.path().join("raced.sqlite"); |
| 69 | let replica = Replica::open_or_create(&path, || { | |
| 69 | let replica = Replica::open_or_create(&path, None, || { | |
| 70 | 70 | drop(Replica::create(&path, &source)?); |
| 71 | 71 | Ok(source.clone()) |
| 72 | 72 | }) |
| 73 | 73 | .unwrap(); |
| 74 | 74 | assert_eq!(replica.pages().unwrap()[0].1, "Raced"); |
| 75 | 75 | drop(replica); |
| 76 | let reopened = Replica::open_or_create(&path, || unreachable!()).unwrap(); | |
| 76 | let reopened = Replica::open_or_create(&path, None, || unreachable!()).unwrap(); | |
| 77 | 77 | assert_eq!(reopened.pages().unwrap()[0].1, "Raced"); |
| 78 | 78 | } |
| 79 | 79 |
crates/notebook/tests/package.rs created+265| ... | ... | @@ -0,0 +1,265 @@ |
| 1 | //! OneNote packages and the copies Save As writes (`corpus/notebook-package`). | |
| 2 | ||
| 3 | use notebook::{package, session::Notebook}; | |
| 4 | use std::path::Path; | |
| 5 | ||
| 6 | fn header(image: &[u8]) -> onestore::Header { | |
| 7 | onestore::Store::parse(image).unwrap().header | |
| 8 | } | |
| 9 | ||
| 10 | /// Each entry of the table of contents `image` holds: its filename and file identity. | |
| 11 | fn listed(image: &[u8]) -> Vec<(String, [u8; 16])> { | |
| 12 | let store = onestore::Store::parse(image).unwrap(); | |
| 13 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | |
| 14 | let document = onestore::document::Document::parse(&index).unwrap(); | |
| 15 | let revision = document.active(document.root).unwrap(); | |
| 16 | let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind | |
| 17 | else { | |
| 18 | panic!() | |
| 19 | }; | |
| 20 | (entries.iter()) | |
| 21 | .map(|id| match &revision.nodes[id].kind { | |
| 22 | onestore::document::Kind::Toc { | |
| 23 | filename, identity, .. | |
| 24 | } => (filename.clone().unwrap_or_default(), identity.unwrap()), | |
| 25 | _ => panic!(), | |
| 26 | }) | |
| 27 | .collect() | |
| 28 | } | |
| 29 | ||
| 30 | /// A notebook of a section, a group holding one, and a page deleted into the recycle bin. | |
| 31 | fn notebook(root: &Path, cache: &Path) -> Notebook { | |
| 32 | let page = |title: &str| onestore::PageCreation::new(None, Some(title), "Author").unwrap(); | |
| 33 | let mut notebook = Notebook::create(root, cache, 0x00d7ff, &page("First")).unwrap(); | |
| 34 | notebook.create_group("", "Group").unwrap(); | |
| 35 | notebook | |
| 36 | .create_section("Group", "Inner", &page("Inside")) | |
| 37 | .unwrap(); | |
| 38 | notebook | |
| 39 | .create_section("", "Doomed", &page("Binned")) | |
| 40 | .unwrap(); | |
| 41 | let image = notebook.read_section("Doomed.one").unwrap(); | |
| 42 | let pages = notebook::session::stored_pages(&image).unwrap(); | |
| 43 | notebook | |
| 44 | .recycle_pages(&[pages[0].page.clone()], "Author") | |
| 45 | .unwrap(); | |
| 46 | notebook.delete("Doomed.one").unwrap(); | |
| 47 | notebook | |
| 48 | } | |
| 49 | ||
| 50 | /// Pack writes the notebook's tables of contents and sections, the recycle bin left out, | |
| 51 | /// outside any notebook; Unpack Notebook gives every file a new identity, placed under its | |
| 52 | /// folder's table of contents, whose entries follow, and the notebook opens as it was. | |
| 53 | /// `NOTEBOOK_PACKAGE_EXPORT` names a new directory receiving the package and the notebook | |
| 54 | /// unpacked, for OneNote 2010 to unpack and to open cold. | |
| 55 | #[test] | |
| 56 | fn a_notebook_packs_and_unpacks_as_onenote_packs_one() { | |
| 57 | let temporary = tempfile::tempdir().unwrap(); | |
| 58 | let root = temporary.path().join("Packed"); | |
| 59 | let cache = temporary.path().join("cache"); | |
| 60 | let notebook = notebook(&root, &cache); | |
| 61 | let files = package::notebook_files(&notebook, |_| None).unwrap(); | |
| 62 | let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); | |
| 63 | assert_eq!( | |
| 64 | paths, | |
| 65 | [ | |
| 66 | "Open Notebook.onetoc2", | |
| 67 | "New Section 1.one", | |
| 68 | "Group/Open Notebook.onetoc2", | |
| 69 | "Group/Inner.one" | |
| 70 | ] | |
| 71 | ); | |
| 72 | let packed = package::pack(files.clone()).unwrap(); | |
| 73 | let read = package::read(&packed, 1 << 30).unwrap(); | |
| 74 | assert_eq!(read.len(), files.len()); | |
| 75 | for ((path, bytes), (stored, original)) in read.iter().zip(&files) { | |
| 76 | assert_eq!(path, stored); | |
| 77 | let (unplaced, placed) = (header(bytes), header(original)); | |
| 78 | assert_eq!( | |
| 79 | unplaced.file_id, placed.file_id, | |
| 80 | "{path} keeps its identity" | |
| 81 | ); | |
| 82 | assert_eq!( | |
| 83 | (unplaced.ancestor, unplaced.name_crc), | |
| 84 | ([0; 16], 0), | |
| 85 | "{path}" | |
| 86 | ); | |
| 87 | assert_eq!(bytes[..128], original[..128]); | |
| 88 | assert_eq!(bytes[148..], original[148..]); | |
| 89 | } | |
| 90 | ||
| 91 | let unpacked = temporary.path().join("Unpacked"); | |
| 92 | package::unpack(read, &unpacked).unwrap(); | |
| 93 | let opened = Notebook::open(&unpacked, &cache).unwrap(); | |
| 94 | let catalog = opened.catalog(); | |
| 95 | let sections = |folder: &notebook::discover::Folder| -> Vec<String> { | |
| 96 | (folder.sections.iter()) | |
| 97 | .map(|section| section.path.clone()) | |
| 98 | .collect() | |
| 99 | }; | |
| 100 | assert_eq!(sections(catalog), ["New Section 1.one"]); | |
| 101 | assert_eq!(sections(&catalog.groups[0]), ["Group/Inner.one"]); | |
| 102 | assert!(catalog.unavailable.is_empty() && catalog.groups[0].unavailable.is_empty()); | |
| 103 | let read = |path: &str| std::fs::read(unpacked.join(path)).unwrap(); | |
| 104 | let root_toc = read("Open Notebook.onetoc2"); | |
| 105 | let group_toc = read("Group/Open Notebook.onetoc2"); | |
| 106 | let section = read("New Section 1.one"); | |
| 107 | let inner = read("Group/Inner.one"); | |
| 108 | for (unpacked, (_, original)) in [&root_toc, &section, &group_toc, &inner] | |
| 109 | .into_iter() | |
| 110 | .zip(&files) | |
| 111 | { | |
| 112 | assert_ne!(header(unpacked).file_id, header(original).file_id); | |
| 113 | } | |
| 114 | assert_eq!(header(&root_toc).ancestor, [0; 16]); | |
| 115 | assert_eq!(header(&section).ancestor, header(&root_toc).file_id); | |
| 116 | assert_eq!(header(&group_toc).ancestor, header(&root_toc).file_id); | |
| 117 | assert_eq!(header(&inner).ancestor, header(&group_toc).file_id); | |
| 118 | // The recycle bin's entry stays, as in OneNote's packages, naming a folder not there. | |
| 119 | let bin = listed(&files[0].1)[2].clone(); | |
| 120 | assert_eq!(bin.0, "OneNote_RecycleBin"); | |
| 121 | assert_eq!( | |
| 122 | listed(&root_toc), | |
| 123 | [ | |
| 124 | ("New Section 1.one".to_owned(), header(&section).file_id), | |
| 125 | ("Group".to_owned(), header(&group_toc).file_id), | |
| 126 | bin | |
| 127 | ] | |
| 128 | ); | |
| 129 | assert_eq!( | |
| 130 | listed(&group_toc), | |
| 131 | [("Inner.one".to_owned(), header(&inner).file_id)] | |
| 132 | ); | |
| 133 | let pages = notebook::session::stored_pages(&inner).unwrap(); | |
| 134 | assert_eq!(pages[0].page.title, "Inside"); | |
| 135 | // Placed as they are, nothing is re-identified when the notebook is read again. | |
| 136 | drop(opened); | |
| 137 | let reopened = Notebook::open(&unpacked, &cache).unwrap(); | |
| 138 | assert_eq!( | |
| 139 | reopened.catalog().sections[0].file_id, | |
| 140 | header(&section).file_id | |
| 141 | ); | |
| 142 | assert!( | |
| 143 | package::unpack(Vec::new(), &unpacked).is_err(), | |
| 144 | "the folder is new" | |
| 145 | ); | |
| 146 | ||
| 147 | if let Some(directory) = std::env::var_os("NOTEBOOK_PACKAGE_EXPORT") { | |
| 148 | let directory = Path::new(&directory); | |
| 149 | std::fs::create_dir(directory).unwrap(); | |
| 150 | std::fs::write(directory.join("Packed.onepkg"), &packed).unwrap(); | |
| 151 | copy(&unpacked, &directory.join("Unpacked")); | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | fn copy(from: &Path, to: &Path) { | |
| 156 | std::fs::create_dir(to).unwrap(); | |
| 157 | for entry in std::fs::read_dir(from).unwrap() { | |
| 158 | let entry = entry.unwrap(); | |
| 159 | if entry.file_type().unwrap().is_dir() { | |
| 160 | copy(&entry.path(), &to.join(entry.file_name())); | |
| 161 | } else { | |
| 162 | std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); | |
| 163 | } | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | /// OneNote 2010's own package (LZX) unpacks and opens. | |
| 168 | #[test] | |
| 169 | fn onenotes_package_unpacks() { | |
| 170 | let package = Path::new(env!("CARGO_MANIFEST_DIR")) | |
| 171 | .join("../../corpus/notebook-package/native/Lab.onepkg"); | |
| 172 | let files = package::read(&std::fs::read(package).unwrap(), 1 << 30).unwrap(); | |
| 173 | let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); | |
| 174 | assert_eq!(paths, ["Alpha.one", "Beta.one", "Open Notebook.onetoc2"]); | |
| 175 | let temporary = tempfile::tempdir().unwrap(); | |
| 176 | let root = temporary.path().join("Lab"); | |
| 177 | package::unpack(files, &root).unwrap(); | |
| 178 | let notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); | |
| 179 | let sections: Vec<&str> = (notebook.catalog().sections.iter()) | |
| 180 | .map(|section| section.path.as_str()) | |
| 181 | .collect(); | |
| 182 | assert_eq!(sections, ["Alpha.one", "Beta.one"]); | |
| 183 | let alpha = | |
| 184 | notebook::session::stored_pages(&notebook.read_section("Alpha.one").unwrap()).unwrap(); | |
| 185 | let titles: Vec<&str> = alpha.iter().map(|page| page.page.title.as_str()).collect(); | |
| 186 | assert_eq!(titles, ["Alpha one", "Alpha three"]); | |
| 187 | } | |
| 188 | ||
| 189 | /// A package naming a file outside its folder, or unpacking past the limit, is refused. | |
| 190 | #[test] | |
| 191 | fn a_package_stays_in_its_folder() { | |
| 192 | let section = onestore::create_empty_section("A.one", None).unwrap(); | |
| 193 | for path in ["../A.one", "Group/../../A.one", "C:/A.one", ""] { | |
| 194 | let packed = package::pack(vec![(path.to_owned(), section.clone())]).unwrap(); | |
| 195 | assert!(package::read(&packed, 1 << 30).is_err(), "{path}"); | |
| 196 | } | |
| 197 | let packed = package::pack(vec![("A.one".to_owned(), section.clone())]).unwrap(); | |
| 198 | assert!(package::read(&packed, 10).is_err()); | |
| 199 | } | |
| 200 | ||
| 201 | /// Save As writes a section as a copy outside any notebook, and a page as a section of its | |
| 202 | /// own holding it, as OneNote 2010 does. `NOTEBOOK_SAVE_AS_EXPORT` names a new directory | |
| 203 | /// receiving both, for OneNote 2010 to open cold. | |
| 204 | #[test] | |
| 205 | fn save_as_writes_copies_outside_the_notebook() { | |
| 206 | let temporary = tempfile::tempdir().unwrap(); | |
| 207 | let notebook = notebook( | |
| 208 | &temporary.path().join("Notebook"), | |
| 209 | &temporary.path().join("cache"), | |
| 210 | ); | |
| 211 | let image = notebook.read_section("Group/Inner.one").unwrap(); | |
| 212 | let copy = package::section_copy(image.clone()).unwrap(); | |
| 213 | assert_ne!(header(&copy).file_id, header(&image).file_id); | |
| 214 | assert_eq!(header(&copy).ancestor, [0; 16]); | |
| 215 | assert_eq!( | |
| 216 | notebook::session::stored_pages(&copy).unwrap()[0] | |
| 217 | .page | |
| 218 | .title, | |
| 219 | "Inside" | |
| 220 | ); | |
| 221 | ||
| 222 | let page = notebook::session::stored_pages(&image) | |
| 223 | .unwrap() | |
| 224 | .remove(0) | |
| 225 | .page; | |
| 226 | let saved = package::page_section(&page, "Inside.one", Some(0xe4a88a), "Author").unwrap(); | |
| 227 | let pages = notebook::session::stored_pages(&saved).unwrap(); | |
| 228 | assert_eq!(pages.len(), 1); | |
| 229 | assert_eq!( | |
| 230 | (pages[0].page.title.as_str(), pages[0].page.identity), | |
| 231 | ("Inside", page.identity) | |
| 232 | ); | |
| 233 | assert_eq!(header(&saved).ancestor, [0; 16]); | |
| 234 | if let Some(directory) = std::env::var_os("NOTEBOOK_SAVE_AS_EXPORT") { | |
| 235 | let directory = Path::new(&directory); | |
| 236 | std::fs::create_dir(directory).unwrap(); | |
| 237 | std::fs::write(directory.join("Inner copy.one"), &copy).unwrap(); | |
| 238 | std::fs::write(directory.join("Inside.one"), &saved).unwrap(); | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | /// Empty Recycle Bin deletes Deleted Pages' pages and every binned section's file, leaving the | |
| 243 | /// bin's table of contents as it was, as OneNote 2010 does. | |
| 244 | #[test] | |
| 245 | fn emptying_the_recycle_bin_leaves_its_table_of_contents() { | |
| 246 | let temporary = tempfile::tempdir().unwrap(); | |
| 247 | let root = temporary.path().join("Notebook"); | |
| 248 | let mut notebook = notebook(&root, &temporary.path().join("cache")); | |
| 249 | let bin = root.join("OneNote_RecycleBin"); | |
| 250 | let toc = std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(); | |
| 251 | assert!(bin.join("Doomed.one").exists()); | |
| 252 | notebook.empty_recycle_bin().unwrap(); | |
| 253 | assert!(!bin.join("Doomed.one").exists()); | |
| 254 | let deleted = std::fs::read(bin.join("OneNote_DeletedPages.one")).unwrap(); | |
| 255 | assert!( | |
| 256 | notebook::session::stored_pages(&deleted) | |
| 257 | .unwrap() | |
| 258 | .is_empty() | |
| 259 | ); | |
| 260 | assert_eq!( | |
| 261 | std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(), | |
| 262 | toc | |
| 263 | ); | |
| 264 | notebook.empty_recycle_bin().unwrap(); | |
| 265 | } |
crates/notebook/tests/protected.rs+634-101| ... | ... | @@ -1,133 +1,666 @@ |
| 1 | #![cfg(feature = "protected")] | |
| 1 | //! Password-protected sections: unlocking, sealed queues, publishing, merging and conflict | |
| 2 | //! pages, and setting, changing and removing a password. | |
| 2 | 3 | |
| 3 | use notebook::{discover::SectionState, session::Notebook}; | |
| 4 | use notebook::{EditStatus, Replica, discover::SectionState, session::Notebook}; | |
| 5 | use onestore::{ | |
| 6 | Arena, ExGuid, Section, | |
| 7 | op::{Edit, Op, PageOp}, | |
| 8 | page::{Page, PageObject}, | |
| 9 | protected::{Error, Key, rekey}, | |
| 10 | }; | |
| 4 | 11 | use std::path::Path; |
| 5 | 12 | |
| 6 | /// A locked section is discovered as such and reads with its password, page by page. | |
| 7 | #[test] | |
| 8 | fn a_locked_section_unlocks_for_reading() { | |
| 9 | let root = Path::new(concat!( | |
| 10 | env!("CARGO_MANIFEST_DIR"), | |
| 11 | "/../../corpus/native-protected-boundaries" | |
| 12 | )); | |
| 13 | #[path = "support/server.rs"] | |
| 14 | mod server; | |
| 15 | use server::Server; | |
| 16 | ||
| 17 | fn fixture(name: &str) -> (std::path::PathBuf, String) { | |
| 18 | let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(name); | |
| 13 | 19 | let manifest: serde_json::Value = |
| 14 | 20 | serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); |
| 15 | let password = manifest["password"].as_str().unwrap(); | |
| 21 | (root, manifest["password"].as_str().unwrap().to_owned()) | |
| 22 | } | |
| 23 | ||
| 24 | /// A copy of a notebook folder. | |
| 25 | fn copy(from: &Path, to: &Path) { | |
| 26 | std::fs::create_dir_all(to).unwrap(); | |
| 27 | for entry in std::fs::read_dir(from).unwrap() { | |
| 28 | let entry = entry.unwrap(); | |
| 29 | std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | /// A protected one-page section holding `text`, with its key. | |
| 34 | fn protected(text: &str) -> (Vec<u8>, Key) { | |
| 35 | let plain = onestore::create_section("sealed.one", text, "Fixture").unwrap(); | |
| 36 | let key = Key::new("fixture password").unwrap(); | |
| 37 | (rekey(&plain, None, Some(&key)).unwrap(), key) | |
| 38 | } | |
| 39 | ||
| 40 | /// The first text object of `image` with its page. | |
| 41 | fn first_text(image: &[u8], key: &Key) -> (ExGuid, ExGuid, String) { | |
| 42 | let arena = Arena::default(); | |
| 43 | let mut section = Section::unlock(&arena, image.to_vec(), key).unwrap(); | |
| 44 | let (space, ..) = section.pages().unwrap()[0]; | |
| 45 | let page = section.page(space).unwrap(); | |
| 46 | texts(&page) | |
| 47 | .into_iter() | |
| 48 | .next() | |
| 49 | .map(|(id, text)| (space, id, text)) | |
| 50 | .unwrap() | |
| 51 | } | |
| 52 | ||
| 53 | fn texts(page: &Page) -> Vec<(ExGuid, String)> { | |
| 54 | page.objects | |
| 55 | .iter() | |
| 56 | .filter_map(|object| match object { | |
| 57 | PageObject::Outline(outline) => Some(&outline.paragraphs), | |
| 58 | _ => None, | |
| 59 | }) | |
| 60 | .flatten() | |
| 61 | .filter_map(|paragraph| { | |
| 62 | let text = paragraph.text()?; | |
| 63 | Some((text.id, text.text.text().to_owned())) | |
| 64 | }) | |
| 65 | .collect() | |
| 66 | } | |
| 67 | ||
| 68 | fn replace(space: ExGuid, text: ExGuid, range: std::ops::Range<u32>, with: &str) -> Edit { | |
| 69 | Edit { | |
| 70 | at: 134_000_000_000_000_000, | |
| 71 | ops: vec![Op::Page { | |
| 72 | space, | |
| 73 | op: PageOp::Text { | |
| 74 | text, | |
| 75 | range, | |
| 76 | with: with.into(), | |
| 77 | }, | |
| 78 | }], | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | /// Whether any file of a replica holds `clear`, as UTF-8 or UTF-16. | |
| 83 | fn leaks(replica: &Path, clear: &str) -> bool { | |
| 84 | let utf16: Vec<u8> = clear.encode_utf16().flat_map(u16::to_le_bytes).collect(); | |
| 85 | ["", "-wal", "-shm"].iter().any(|suffix| { | |
| 86 | let mut file = replica.as_os_str().to_owned(); | |
| 87 | file.push(suffix); | |
| 88 | std::fs::read(file).is_ok_and(|bytes| { | |
| 89 | [clear.as_bytes(), &utf16[..]] | |
| 90 | .iter() | |
| 91 | .any(|clear| bytes.windows(clear.len()).any(|w| w == *clear)) | |
| 92 | }) | |
| 93 | }) | |
| 94 | } | |
| 95 | ||
| 96 | /// A locked section is discovered as such, unlocks with its password and opens as a session. | |
| 97 | #[test] | |
| 98 | fn a_locked_section_unlocks_into_a_session() { | |
| 99 | let (root, password) = fixture("native-protected-boundaries"); | |
| 16 | 100 | let temporary = tempfile::tempdir().unwrap(); |
| 17 | let notebook = Notebook::open(root.join("notebook"), temporary.path()).unwrap(); | |
| 101 | let folder = temporary.path().join("notebook"); | |
| 102 | copy(&root.join("notebook"), &folder); | |
| 103 | let notebook = Notebook::open(&folder, temporary.path().join("cache")).unwrap(); | |
| 18 | 104 | let section = &notebook.catalog().sections[0]; |
| 19 | 105 | assert!(matches!(section.state, SectionState::Locked)); |
| 20 | let pages = notebook.unlock(&section.path, password).unwrap().pages; | |
| 21 | assert_eq!(pages.len(), 11); | |
| 22 | assert!(pages.iter().all(|(_, page)| !page.title.is_empty())); | |
| 23 | 106 | assert!(matches!( |
| 24 | 107 | notebook.unlock(&section.path, "wrong"), |
| 25 | Err(notebook::Error::Protected( | |
| 26 | onestore::protected::Error::PasswordMismatch | |
| 27 | )) | |
| 108 | Err(notebook::Error::Protected(Error::PasswordMismatch)) | |
| 109 | )); | |
| 110 | assert!(notebook.section(&section.path, || {}).is_err()); | |
| 111 | let key = notebook.unlock(&section.path, &password).unwrap(); | |
| 112 | let session = notebook | |
| 113 | .section_unlocked(&section.path, &key, || {}) | |
| 114 | .unwrap(); | |
| 115 | let pages = session.pages().unwrap(); | |
| 116 | assert_eq!(pages.len(), 11); | |
| 117 | assert!(pages.iter().all(|(_, title, _)| !title.is_empty())); | |
| 118 | let title = &pages[0].1; | |
| 119 | session.close().unwrap(); | |
| 120 | // The replica holds the section as its file does: encrypted. | |
| 121 | assert!(!leaks( | |
| 122 | &notebook.replica_path(&section.path).unwrap(), | |
| 123 | title | |
| 28 | 124 | )); |
| 29 | 125 | } |
| 30 | 126 | |
| 31 | /// An unlocked page is edited as ops stored under the section's key, then reads back with | |
| 32 | /// the same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for | |
| 33 | /// a cold reopen in OneNote. | |
| 127 | /// Queued edits and their payloads are sealed in the replica, which opens again only under | |
| 128 | /// the section's key, and publish into the file under it. | |
| 34 | 129 | #[test] |
| 35 | fn an_unlocked_page_is_saved_under_the_section_key() { | |
| 36 | use onestore::page::{PageObject, Paragraph, text::new_id}; | |
| 37 | let root = Path::new(concat!( | |
| 38 | env!("CARGO_MANIFEST_DIR"), | |
| 39 | "/../../corpus/native-encrypted" | |
| 130 | fn queued_edits_are_sealed_and_publish_under_the_key() { | |
| 131 | const SECRET: &str = "Queued in confidence"; | |
| 132 | let (source, key) = protected("Opening line"); | |
| 133 | let (space, text, _) = first_text(&source, &key); | |
| 134 | let directory = tempfile::tempdir().unwrap(); | |
| 135 | let path = directory.path().join("cache.sqlite"); | |
| 136 | let cache = Replica::open_or_create(&path, Some(&key), || Ok(source.clone())).unwrap(); | |
| 137 | cache | |
| 138 | .apply("Fixture", replace(space, text, 0..0, SECRET)) | |
| 139 | .unwrap(); | |
| 140 | let mut page = cache.page(space).unwrap(); | |
| 141 | let PageObject::Outline(outline) = page | |
| 142 | .objects | |
| 143 | .iter_mut() | |
| 144 | .find(|object| matches!(object, PageObject::Outline(_))) | |
| 145 | .unwrap() | |
| 146 | else { | |
| 147 | unreachable!() | |
| 148 | }; | |
| 149 | let mut file = outline.paragraphs[0].clone(); | |
| 150 | file.id = onestore::page::text::new_id().unwrap(); | |
| 151 | file.content = onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { | |
| 152 | id: onestore::page::text::new_id().unwrap(), | |
| 153 | filename: "sealed.txt".into(), | |
| 154 | source_path: None, | |
| 155 | size: Some([24.0, 24.0]), | |
| 156 | layout: Default::default(), | |
| 157 | bytes: Some(std::sync::Arc::from(SECRET.as_bytes())), | |
| 158 | preview: None, | |
| 159 | recording: None, | |
| 160 | tags: Vec::new(), | |
| 161 | }); | |
| 162 | outline.paragraphs.push(file); | |
| 163 | let before = cache.page(space).unwrap(); | |
| 164 | let ops = onestore::op::lower_page(&before, &page).unwrap(); | |
| 165 | cache | |
| 166 | .apply( | |
| 167 | "Fixture", | |
| 168 | Edit { | |
| 169 | at: 134_000_000_000_000_001, | |
| 170 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), | |
| 171 | }, | |
| 172 | ) | |
| 173 | .unwrap(); | |
| 174 | drop(cache); | |
| 175 | assert!(!leaks(&path, SECRET)); | |
| 176 | assert!(Replica::open(&path).is_err()); | |
| 177 | assert!( | |
| 178 | Replica::open_or_create( | |
| 179 | &path, | |
| 180 | Some(&Key::new("another").unwrap()), | |
| 181 | || unreachable!() | |
| 182 | ) | |
| 183 | .is_err() | |
| 184 | ); | |
| 185 | let cache = Replica::open_or_create(&path, Some(&key), || unreachable!()).unwrap(); | |
| 186 | assert_eq!(cache.pending().unwrap().len(), 2); | |
| 187 | let mut server = Server::new(&source); | |
| 188 | assert!(matches!( | |
| 189 | cache.sync_once(&mut server).unwrap().edit, | |
| 190 | Some((_, EditStatus::Published { .. })) | |
| 40 | 191 | )); |
| 41 | let manifest: serde_json::Value = | |
| 42 | serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); | |
| 43 | let password = manifest["password"].as_str().unwrap(); | |
| 192 | drop(cache); | |
| 193 | assert!(!leaks(&path, SECRET)); | |
| 194 | assert!( | |
| 195 | !server | |
| 196 | .durable | |
| 197 | .windows(SECRET.len()) | |
| 198 | .any(|w| w == SECRET.as_bytes()) | |
| 199 | ); | |
| 200 | let arena = Arena::default(); | |
| 201 | let stored = Section::unlock(&arena, server.durable.clone(), &key).unwrap(); | |
| 202 | let published = stored.page(space).unwrap(); | |
| 203 | assert!(texts(&published)[0].1.starts_with(SECRET)); | |
| 204 | assert!(format!("{published:?}").contains("sealed.txt")); | |
| 205 | let archived = directory.path().join("recovery.sqlite"); | |
| 206 | Replica::open_or_create(&path, Some(&key), || unreachable!()) | |
| 207 | .unwrap() | |
| 208 | .export_recovery(&archived) | |
| 209 | .unwrap(); | |
| 210 | assert!(!leaks(&archived, SECRET)); | |
| 211 | assert!(notebook::Recovery::open(&archived).is_err()); | |
| 212 | assert!(notebook::Recovery::open_unlocked(&archived, &key).is_ok()); | |
| 213 | } | |
| 214 | ||
| 215 | /// Edits of the same text on two copies of a protected section merge as an ordinary | |
| 216 | /// section's do: the remote's stays the page, the local one becomes a conflict page, and | |
| 217 | /// every revision stays under the key. | |
| 218 | #[test] | |
| 219 | fn a_conflict_in_a_protected_section_becomes_a_conflict_page() { | |
| 220 | let (source, key) = protected("alpha beta gamma"); | |
| 221 | let (space, text, _) = first_text(&source, &key); | |
| 222 | let directory = tempfile::tempdir().unwrap(); | |
| 223 | let cache = Replica::open_or_create(directory.path().join("c.sqlite"), Some(&key), || { | |
| 224 | Ok(source.clone()) | |
| 225 | }) | |
| 226 | .unwrap(); | |
| 227 | cache | |
| 228 | .apply("Local", replace(space, text, 6..10, "LOCAL")) | |
| 229 | .unwrap(); | |
| 230 | let remote = { | |
| 231 | let arena = Arena::default(); | |
| 232 | let mut section = Section::unlock(&arena, source.clone(), &key).unwrap(); | |
| 233 | section | |
| 234 | .apply("Remote", &replace(space, text, 6..10, "REMOTE")) | |
| 235 | .unwrap(); | |
| 236 | section.seal().unwrap(); | |
| 237 | section.image() | |
| 238 | }; | |
| 239 | let mut server = Server::new(&remote); | |
| 240 | assert!(matches!( | |
| 241 | cache.sync_once(&mut server).unwrap().edit, | |
| 242 | Some((_, EditStatus::Published { .. })) | |
| 243 | )); | |
| 244 | let arena = Arena::default(); | |
| 245 | let mut merged = Section::unlock(&arena, server.durable.clone(), &key).unwrap(); | |
| 246 | assert_eq!( | |
| 247 | texts(&merged.page(space).unwrap())[0].1, | |
| 248 | "alpha REMOTE gamma" | |
| 249 | ); | |
| 250 | let conflicts = merged.conflicts().unwrap(); | |
| 251 | assert_eq!(conflicts.len(), 1); | |
| 252 | let conflict = merged.page(conflicts[0].1[0].space).unwrap(); | |
| 253 | assert_eq!(texts(&conflict)[0].1, "alpha LOCAL gamma"); | |
| 254 | let store = onestore::Store::parse(&server.durable).unwrap(); | |
| 255 | assert!( | |
| 256 | onestore::RevisionIndex::parse(&store) | |
| 257 | .unwrap() | |
| 258 | .spaces | |
| 259 | .values() | |
| 260 | .flat_map(|space| space.revisions.values()) | |
| 261 | .all(|revision| revision.encrypted) | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 265 | /// Setting a password rewrites the section under new identities that its TOC follows and | |
| 266 | /// drops the plaintext replica; changing and removing it rewrite it again. | |
| 267 | #[test] | |
| 268 | fn a_password_is_set_changed_and_removed_in_a_notebook() { | |
| 269 | const TEXT: &str = "Kept through every password"; | |
| 44 | 270 | let temporary = tempfile::tempdir().unwrap(); |
| 45 | let copy = temporary.path().join("notebook"); | |
| 46 | std::fs::create_dir(&copy).unwrap(); | |
| 47 | for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() { | |
| 48 | let entry = entry.unwrap(); | |
| 49 | std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap(); | |
| 50 | } | |
| 51 | let notebook = Notebook::open(&copy, temporary.path().join("cache")).unwrap(); | |
| 52 | let section = notebook | |
| 53 | .catalog() | |
| 54 | .sections | |
| 55 | .iter() | |
| 56 | .find(|section| matches!(section.state, SectionState::Locked)) | |
| 271 | let folder = temporary.path().join("notebook"); | |
| 272 | let cache = temporary.path().join("cache"); | |
| 273 | let creation = onestore::PageCreation::new(None, Some(TEXT), "Fixture").unwrap(); | |
| 274 | let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); | |
| 275 | let path = notebook.catalog().sections[0].path.clone(); | |
| 276 | let plain = notebook.replica_path(&path).unwrap(); | |
| 277 | notebook.section(&path, || {}).unwrap().close().unwrap(); | |
| 278 | assert!(plain.exists()); | |
| 279 | let identity = notebook.catalog().sections[0].file_id; | |
| 280 | ||
| 281 | let first = notebook | |
| 282 | .set_password(&path, None, Some("first")) | |
| 57 | 283 | .unwrap() |
| 58 | .path | |
| 59 | .clone(); | |
| 60 | let mut unlocked = notebook.unlock(&section, password).unwrap(); | |
| 61 | let (space, mut page) = unlocked.pages[0].clone(); | |
| 62 | let before = page.clone(); | |
| 63 | let paragraphs = page | |
| 64 | .objects | |
| 65 | .iter_mut() | |
| 66 | .find_map(|object| match object { | |
| 67 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), | |
| 68 | _ => None, | |
| 69 | }) | |
| 70 | 284 | .unwrap(); |
| 71 | let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); | |
| 72 | let mut added = paragraphs[at].clone(); | |
| 73 | added.id = new_id().unwrap(); | |
| 74 | added.style = None; | |
| 75 | added.lists.clear(); | |
| 76 | added.tags.clear(); | |
| 77 | let text = added.text_mut().unwrap(); | |
| 78 | text.id = new_id().unwrap(); | |
| 79 | let format = text.text.format_at(0).unwrap().clone(); | |
| 80 | text.text = Paragraph::new( | |
| 81 | "Written while protected 🦀".to_owned(), | |
| 82 | onestore::document::Format { | |
| 83 | font: Some("Calibri".into()), | |
| 84 | font_size: Some(11.0), | |
| 85 | language: Some(1033), | |
| 86 | ..Default::default() | |
| 87 | }, | |
| 285 | assert!(!plain.exists()); | |
| 286 | let section = &notebook.catalog().sections[0]; | |
| 287 | assert!(matches!(section.state, SectionState::Locked)); | |
| 288 | assert_ne!(section.file_id, identity); | |
| 289 | assert!( | |
| 290 | notebook | |
| 291 | .catalog() | |
| 292 | .toc | |
| 293 | .as_ref() | |
| 294 | .unwrap() | |
| 295 | .unresolved | |
| 296 | .is_empty() | |
| 88 | 297 | ); |
| 89 | paragraphs.insert(at + 1, added); | |
| 90 | paragraphs[at] | |
| 91 | .text_mut() | |
| 298 | let image = notebook.read_section(&path).unwrap(); | |
| 299 | let utf16: Vec<u8> = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect(); | |
| 300 | assert!(!image.windows(utf16.len()).any(|w| w == utf16)); | |
| 301 | let key = notebook.unlock(&path, "first").unwrap(); | |
| 302 | assert_eq!(key.secret(), first.secret()); | |
| 303 | let session = notebook.section_unlocked(&path, &key, || {}).unwrap(); | |
| 304 | assert_eq!(session.pages().unwrap()[0].1, TEXT); | |
| 305 | session.close().unwrap(); | |
| 306 | assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT)); | |
| 307 | ||
| 308 | assert!(notebook.set_password(&path, None, Some("second")).is_err()); | |
| 309 | let second = notebook | |
| 310 | .set_password(&path, Some(&key), Some("second")) | |
| 92 | 311 | .unwrap() |
| 93 | .text | |
| 94 | .append(Paragraph::new( | |
| 95 | " and edited under its key".to_owned(), | |
| 96 | format, | |
| 97 | )) | |
| 98 | 312 | .unwrap(); |
| 99 | let edit = onestore::op::Edit { | |
| 100 | at: 134_000_000_000_000_000, | |
| 101 | ops: onestore::op::lower_page(&before, &page) | |
| 313 | assert!(notebook.unlock(&path, "first").is_err()); | |
| 314 | let session = notebook.section_unlocked(&path, &second, || {}).unwrap(); | |
| 315 | assert_eq!(session.pages().unwrap()[0].1, TEXT); | |
| 316 | session.close().unwrap(); | |
| 317 | ||
| 318 | assert!( | |
| 319 | notebook | |
| 320 | .set_password(&path, Some(&second), None) | |
| 102 | 321 | .unwrap() |
| 103 | .into_iter() | |
| 104 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 105 | .collect(), | |
| 106 | }; | |
| 322 | .is_none() | |
| 323 | ); | |
| 324 | let section = &notebook.catalog().sections[0]; | |
| 325 | assert!(matches!(section.state, SectionState::Readable { .. })); | |
| 326 | let session = notebook.section(&path, || {}).unwrap(); | |
| 327 | assert_eq!(session.pages().unwrap()[0].1, TEXT); | |
| 328 | } | |
| 329 | ||
| 330 | /// A section whose edits wait to be published keeps its password until they are. | |
| 331 | #[test] | |
| 332 | fn a_password_waits_for_queued_edits() { | |
| 333 | let temporary = tempfile::tempdir().unwrap(); | |
| 334 | let folder = temporary.path().join("notebook"); | |
| 335 | let creation = onestore::PageCreation::new(None, Some("Waiting"), "Fixture").unwrap(); | |
| 336 | let mut notebook = Notebook::create( | |
| 337 | &folder, | |
| 338 | temporary.path().join("cache"), | |
| 339 | 0x00f0_a0c0, | |
| 340 | &creation, | |
| 341 | ) | |
| 342 | .unwrap(); | |
| 343 | let path = notebook.catalog().sections[0].path.clone(); | |
| 344 | let session = notebook.section(&path, || {}).unwrap(); | |
| 345 | session.set_offline(true); | |
| 346 | let (space, ..) = session.pages().unwrap()[0].clone(); | |
| 347 | let page = session.page(space).unwrap(); | |
| 348 | let mut edited = page.clone(); | |
| 349 | edited.title = "Waiting still".into(); | |
| 350 | let ops = onestore::op::lower_page(&page, &edited).unwrap(); | |
| 351 | session | |
| 352 | .apply( | |
| 353 | "Fixture", | |
| 354 | Edit { | |
| 355 | at: 134_000_000_000_000_000, | |
| 356 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), | |
| 357 | }, | |
| 358 | ) | |
| 359 | .unwrap(); | |
| 360 | session.written().unwrap(); | |
| 361 | session.close().unwrap(); | |
| 362 | assert!(notebook.set_password(&path, None, Some("early")).is_err()); | |
| 363 | assert!(matches!( | |
| 364 | notebook.catalog().sections[0].state, | |
| 365 | SectionState::Readable { .. } | |
| 366 | )); | |
| 367 | } | |
| 368 | ||
| 369 | /// A notebook `here` makes, also opened `there` as another device opens it, with its first | |
| 370 | /// section's path and the page title it starts with. | |
| 371 | fn two_devices(temporary: &Path, title: &str) -> (Notebook, Notebook, String) { | |
| 372 | let folder = temporary.join("notebook"); | |
| 373 | let creation = onestore::PageCreation::new(None, Some(title), "Fixture").unwrap(); | |
| 374 | let here = Notebook::create(&folder, temporary.join("here"), 0x00f0_a0c0, &creation).unwrap(); | |
| 375 | let path = here.catalog().sections[0].path.clone(); | |
| 376 | let there = Notebook::open(&folder, temporary.join("there")).unwrap(); | |
| 377 | (here, there, path) | |
| 378 | } | |
| 379 | ||
| 380 | /// Another device's replica of a section, plaintext, holds none of it once the section is | |
| 381 | /// protected here and that device reads the notebook again. | |
| 382 | #[test] | |
| 383 | fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() { | |
| 384 | const TITLE: &str = "Read on the other device"; | |
| 385 | let temporary = tempfile::tempdir().unwrap(); | |
| 386 | let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); | |
| 387 | there.section(&path, || {}).unwrap().close().unwrap(); | |
| 388 | let replica = there.replica_path(&path).unwrap(); | |
| 389 | assert!(leaks(&replica, TITLE)); | |
| 390 | here.set_password(&path, None, Some("secret")).unwrap(); | |
| 391 | there.refresh().unwrap(); | |
| 392 | assert!(!leaks(&replica, TITLE)); | |
| 393 | let again = Notebook::open( | |
| 394 | temporary.path().join("notebook"), | |
| 395 | temporary.path().join("there"), | |
| 396 | ); | |
| 107 | 397 | assert!(matches!( |
| 108 | notebook.apply_unlocked(&section, "wrong", &mut unlocked, "Rust", &edit), | |
| 109 | Err(notebook::Error::Protected( | |
| 110 | onestore::protected::Error::PasswordMismatch | |
| 111 | )) | |
| 398 | again.unwrap().catalog().sections[0].state, | |
| 399 | SectionState::Locked | |
| 112 | 400 | )); |
| 113 | let mut stale = notebook.unlock(&section, password).unwrap(); | |
| 114 | notebook | |
| 115 | .apply_unlocked(&section, password, &mut unlocked, "Rust", &edit) | |
| 401 | assert!(!leaks(&replica, TITLE)); | |
| 402 | } | |
| 403 | ||
| 404 | /// An open section that another device protects stops synchronizing as protected, and its | |
| 405 | /// plaintext goes once it closes. | |
| 406 | #[test] | |
| 407 | fn an_open_section_protected_elsewhere_stops_as_protected() { | |
| 408 | const TITLE: &str = "Open while protected"; | |
| 409 | let temporary = tempfile::tempdir().unwrap(); | |
| 410 | let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); | |
| 411 | let session = there.section(&path, || {}).unwrap(); | |
| 412 | let replica = there.replica_path(&path).unwrap(); | |
| 413 | here.set_password(&path, None, Some("secret")).unwrap(); | |
| 414 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); | |
| 415 | loop { | |
| 416 | session.wake(); | |
| 417 | let state = session.sync_status().unwrap().state(); | |
| 418 | if state == notebook::session::SyncState::Protected { | |
| 419 | break; | |
| 420 | } | |
| 421 | assert!(std::time::Instant::now() < deadline, "still {state:?}"); | |
| 422 | std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 423 | } | |
| 424 | session.close().unwrap(); | |
| 425 | there.refresh().unwrap(); | |
| 426 | assert!(!leaks(&replica, TITLE)); | |
| 427 | } | |
| 428 | ||
| 429 | /// Edits another device queued before the section was protected wait in its replica until | |
| 430 | /// that device unlocks the section, then publish under the key, the page they changed coming | |
| 431 | /// back as a copy, as a page another client removed does; nothing stays in the clear. | |
| 432 | #[test] | |
| 433 | fn edits_queued_before_a_password_publish_under_it_once_unlocked() { | |
| 434 | const TITLE: &str = "Protected meanwhile"; | |
| 435 | const TYPED: &str = "Typed offline meanwhile"; | |
| 436 | let temporary = tempfile::tempdir().unwrap(); | |
| 437 | let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); | |
| 438 | let session = there.section(&path, || {}).unwrap(); | |
| 439 | session.set_offline(true); | |
| 440 | let (space, ..) = session.pages().unwrap()[0].clone(); | |
| 441 | let page = session.page(space).unwrap(); | |
| 442 | let Some(PageObject::Title(title)) = page.objects.first() else { | |
| 443 | panic!("no title") | |
| 444 | }; | |
| 445 | let text = title.outlines[0].paragraphs[0].text().unwrap().id; | |
| 446 | let end = TITLE.encode_utf16().count() as u32; | |
| 447 | session | |
| 448 | .apply("Fixture", replace(space, text, 0..end, TYPED)) | |
| 116 | 449 | .unwrap(); |
| 117 | // A view read before that edit no longer matches the file. | |
| 450 | session.written().unwrap(); | |
| 451 | session.close().unwrap(); | |
| 452 | let held = there.replica_path(&path).unwrap(); | |
| 453 | ||
| 454 | here.set_password(&path, None, Some("secret")).unwrap(); | |
| 455 | there.refresh().unwrap(); | |
| 456 | assert_eq!(Replica::open(&held).unwrap().pending().unwrap().len(), 1); | |
| 457 | ||
| 458 | let key = there.unlock(&path, "secret").unwrap(); | |
| 459 | assert!(!held.exists()); | |
| 460 | let session = there.section_unlocked(&path, &key, || {}).unwrap(); | |
| 461 | let titles: Vec<String> = session | |
| 462 | .pages() | |
| 463 | .unwrap() | |
| 464 | .into_iter() | |
| 465 | .map(|(_, title, _)| title) | |
| 466 | .collect(); | |
| 467 | assert_eq!(titles, [TITLE, TYPED]); | |
| 468 | published(&session); | |
| 469 | session.close().unwrap(); | |
| 470 | assert!(!leaks(&there.replica_path(&path).unwrap(), TYPED)); | |
| 471 | let image = there.read_section(&path).unwrap(); | |
| 472 | let utf16: Vec<u8> = TYPED.encode_utf16().flat_map(u16::to_le_bytes).collect(); | |
| 473 | assert!(!image.windows(utf16.len()).any(|w| w == utf16)); | |
| 474 | let stored = notebook::session::stored_pages_unlocked(&image, &key).unwrap(); | |
| 475 | assert!(stored.iter().any(|page| page.page.title == TYPED)); | |
| 476 | } | |
| 477 | ||
| 478 | /// Waits until `section` has published every edit. | |
| 479 | fn published(section: &notebook::session::Section) { | |
| 480 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); | |
| 481 | section.wake(); | |
| 482 | while !section.pending().unwrap().is_empty() { | |
| 483 | assert!( | |
| 484 | std::time::Instant::now() < deadline, | |
| 485 | "edits were not published" | |
| 486 | ); | |
| 487 | std::thread::sleep(std::time::Duration::from_millis(20)); | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | /// Appends `text` to the first paragraph of every page of `section`. | |
| 492 | fn append(section: &notebook::session::Section, text: &str) { | |
| 493 | for (space, ..) in section.pages().unwrap() { | |
| 494 | let page = section.page(space).unwrap(); | |
| 495 | let Some((id, current)) = texts(&page).into_iter().next() else { | |
| 496 | continue; | |
| 497 | }; | |
| 498 | let end = current.encode_utf16().count() as u32; | |
| 499 | section | |
| 500 | .apply("Snowbound Test", replace(space, id, end..end, text)) | |
| 501 | .unwrap(); | |
| 502 | } | |
| 503 | section.written().unwrap(); | |
| 504 | } | |
| 505 | ||
| 506 | /// The native gate's candidate: a notebook whose sections Snowbound protected, edited under | |
| 507 | /// their keys, gave another password, unprotected, and merged into a conflict page, each | |
| 508 | /// from OneNote 2010's pages. `ONESTORE_PROTECTED_EXPORT` names a new directory receiving it | |
| 509 | /// and its passwords for a cold open in OneNote. | |
| 510 | #[test] | |
| 511 | fn a_notebook_protected_through_its_sessions() { | |
| 512 | let source = std::fs::read(concat!( | |
| 513 | env!("CARGO_MANIFEST_DIR"), | |
| 514 | "/../../corpus/protected-sections/source/synthetic.one" | |
| 515 | )) | |
| 516 | .unwrap(); | |
| 517 | let pages = notebook::session::stored_pages(&source).unwrap(); | |
| 518 | let temporary = tempfile::tempdir().unwrap(); | |
| 519 | let folder = temporary.path().join("notebook"); | |
| 520 | let cache = temporary.path().join("cache"); | |
| 521 | let creation = onestore::PageCreation::new(None, Some("Gate"), "Snowbound Test").unwrap(); | |
| 522 | let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); | |
| 523 | let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"]; | |
| 524 | for name in names { | |
| 525 | let path = notebook.create_section("", name, &creation).unwrap(); | |
| 526 | let section = notebook.section(&path, || {}).unwrap(); | |
| 527 | for stored in &pages { | |
| 528 | section.import_page(&stored.page, "Snowbound Test").unwrap(); | |
| 529 | } | |
| 530 | published(&section); | |
| 531 | section.close().unwrap(); | |
| 532 | } | |
| 533 | let path = |name: &str| format!("{name}.one"); | |
| 534 | let mut passwords = serde_json::Map::new(); | |
| 535 | let mut keys = std::collections::BTreeMap::new(); | |
| 536 | for name in names { | |
| 537 | let password = format!("{name} gate password"); | |
| 538 | let key = notebook | |
| 539 | .set_password(&path(name), None, Some(&password)) | |
| 540 | .unwrap() | |
| 541 | .unwrap(); | |
| 542 | passwords.insert(path(name), password.into()); | |
| 543 | keys.insert(name, key); | |
| 544 | } | |
| 545 | // Edited under its key: text on every page, published as dependent revisions. | |
| 546 | let section = notebook | |
| 547 | .section_unlocked(&path("Edited"), &keys["Edited"], || {}) | |
| 548 | .unwrap(); | |
| 549 | for round in ["first", "second", "third"] { | |
| 550 | append(&section, &format!(" Edited {round} under the key.")); | |
| 551 | published(&section); | |
| 552 | } | |
| 553 | section.close().unwrap(); | |
| 554 | // Changed: another password. | |
| 555 | let changed = notebook | |
| 556 | .set_password( | |
| 557 | &path("Changed"), | |
| 558 | Some(&keys["Changed"]), | |
| 559 | Some("Changed again"), | |
| 560 | ) | |
| 561 | .unwrap() | |
| 562 | .unwrap(); | |
| 563 | passwords.insert(path("Changed"), "Changed again".into()); | |
| 564 | let section = notebook | |
| 565 | .section_unlocked(&path("Changed"), &changed, || {}) | |
| 566 | .unwrap(); | |
| 567 | append(&section, " Written under the changed password."); | |
| 568 | published(&section); | |
| 569 | section.close().unwrap(); | |
| 570 | // Removed: no password. | |
| 571 | assert!( | |
| 572 | notebook | |
| 573 | .set_password(&path("Removed"), Some(&keys["Removed"]), None) | |
| 574 | .unwrap() | |
| 575 | .is_none() | |
| 576 | ); | |
| 577 | passwords.insert(path("Removed"), serde_json::Value::Null); | |
| 578 | // Conflicted: two copies edit one paragraph offline; the second to publish keeps a | |
| 579 | // conflict page. | |
| 580 | let second = Notebook::open(&folder, temporary.path().join("second")).unwrap(); | |
| 581 | let key = &keys["Conflicted"]; | |
| 582 | let ours = notebook | |
| 583 | .section_unlocked(&path("Conflicted"), key, || {}) | |
| 584 | .unwrap(); | |
| 585 | let theirs = second | |
| 586 | .section_unlocked(&path("Conflicted"), key, || {}) | |
| 587 | .unwrap(); | |
| 588 | for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] { | |
| 589 | section.set_offline(true); | |
| 590 | append(section, word); | |
| 591 | } | |
| 592 | ours.set_offline(false); | |
| 593 | published(&ours); | |
| 594 | theirs.set_offline(false); | |
| 595 | published(&theirs); | |
| 596 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); | |
| 597 | while theirs.conflicts().unwrap().is_empty() { | |
| 598 | assert!(std::time::Instant::now() < deadline, "no conflict page"); | |
| 599 | std::thread::sleep(std::time::Duration::from_millis(20)); | |
| 600 | } | |
| 601 | published(&theirs); | |
| 602 | ours.close().unwrap(); | |
| 603 | theirs.close().unwrap(); | |
| 604 | ||
| 605 | let notebook = Notebook::open(&folder, temporary.path().join("check")).unwrap(); | |
| 606 | for name in names { | |
| 607 | let image = notebook.read_section(&path(name)).unwrap(); | |
| 608 | let clear = |word: &str| { | |
| 609 | let utf16: Vec<u8> = word.encode_utf16().flat_map(u16::to_le_bytes).collect(); | |
| 610 | image.windows(utf16.len()).any(|w| w == utf16) | |
| 611 | || image.windows(word.len()).any(|w| w == word.as_bytes()) | |
| 612 | }; | |
| 613 | for word in [ | |
| 614 | "Edited first", | |
| 615 | "Ours offline", | |
| 616 | "Theirs offline", | |
| 617 | "changed password", | |
| 618 | ] { | |
| 619 | assert!(!clear(word), "{name} holds {word} in the clear"); | |
| 620 | } | |
| 621 | assert_eq!(clear("positioned outline"), name == "Removed", "{name}"); | |
| 622 | let state = &notebook | |
| 623 | .catalog() | |
| 624 | .sections | |
| 625 | .iter() | |
| 626 | .find(|section| section.path == path(name)) | |
| 627 | .unwrap() | |
| 628 | .state; | |
| 629 | assert_eq!( | |
| 630 | matches!(state, SectionState::Locked), | |
| 631 | name != "Removed", | |
| 632 | "{name}" | |
| 633 | ); | |
| 634 | } | |
| 118 | 635 | assert!( |
| 119 | 636 | notebook |
| 120 | .apply_unlocked(&section, password, &mut stale, "Rust", &edit) | |
| 121 | .is_err() | |
| 637 | .catalog() | |
| 638 | .toc | |
| 639 | .as_ref() | |
| 640 | .unwrap() | |
| 641 | .unresolved | |
| 642 | .is_empty() | |
| 122 | 643 | ); |
| 123 | let (_, stored) = notebook.unlock(&section, password).unwrap().pages.remove(0); | |
| 124 | assert!(stored.objects == page.objects); | |
| 644 | let key = notebook | |
| 645 | .unlock(&path("Conflicted"), "Conflicted gate password") | |
| 646 | .unwrap(); | |
| 647 | let stored = Section::unlock( | |
| 648 | &Arena::default(), | |
| 649 | notebook.read_section(&path("Conflicted")).unwrap(), | |
| 650 | &key, | |
| 651 | ) | |
| 652 | .unwrap() | |
| 653 | .conflicts() | |
| 654 | .unwrap(); | |
| 655 | // Each page's first paragraph clashed, so each keeps a conflict page. | |
| 656 | assert_eq!(stored.len(), 2); | |
| 125 | 657 | if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { |
| 126 | 658 | let export = Path::new(&export); |
| 127 | std::fs::create_dir_all(export).unwrap(); | |
| 128 | for entry in std::fs::read_dir(&copy).unwrap() { | |
| 129 | let entry = entry.unwrap(); | |
| 130 | std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap(); | |
| 131 | } | |
| 659 | copy(&folder, &export.join("notebook")); | |
| 660 | std::fs::write( | |
| 661 | export.join("passwords.json"), | |
| 662 | serde_json::to_vec_pretty(&passwords).unwrap(), | |
| 663 | ) | |
| 664 | .unwrap(); | |
| 132 | 665 | } |
| 133 | 666 | } |
crates/notebook/tests/support/page_schedule.rs+6-44| ... | ... | @@ -5,12 +5,9 @@ use crate::{ |
| 5 | 5 | use notebook::{Remote, Replica}; |
| 6 | 6 | use onestore::{ |
| 7 | 7 | CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction, |
| 8 | document::{Document, Format, Kind, Layout}, | |
| 8 | document::{Document, Kind}, | |
| 9 | 9 | op::SectionOp, |
| 10 | page::{ | |
| 11 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, | |
| 12 | text::new_id, | |
| 13 | }, | |
| 10 | page::{Page, PageObject}, | |
| 14 | 11 | }; |
| 15 | 12 | use std::{io, sync::LazyLock}; |
| 16 | 13 | |
| ... | ... | @@ -54,50 +51,15 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { |
| 54 | 51 | /// Adds a body outline holding one plain paragraph, returning its text identity. A page the |
| 55 | 52 | /// section just created has no body text for `model_ops::insert_outline` to copy formatting from. |
| 56 | 53 | pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { |
| 57 | // The writer creates outline text with the store's default style; the model must state it. | |
| 58 | let format = Format { | |
| 59 | font: Some("Calibri".to_owned()), | |
| 60 | font_size: Some(11.0), | |
| 61 | language: Some(0x409), | |
| 62 | ..Default::default() | |
| 63 | }; | |
| 64 | let content = TextObject { | |
| 65 | id: new_id().unwrap(), | |
| 66 | date_field: None, | |
| 67 | text: Paragraph::new(text.into(), format), | |
| 68 | tags: Vec::new(), | |
| 69 | }; | |
| 70 | let id = content.id; | |
| 71 | let outline = Outline { | |
| 72 | id: new_id().unwrap(), | |
| 73 | title: false, | |
| 74 | min_width: None, | |
| 75 | layout: Layout { | |
| 76 | x: Some(36.0), | |
| 77 | y: Some(36.0), | |
| 78 | ..Default::default() | |
| 79 | }, | |
| 80 | indents: Vec::new(), | |
| 81 | paragraphs: vec![PageParagraph { | |
| 82 | id: new_id().unwrap(), | |
| 83 | parent: None, | |
| 84 | level: 1, | |
| 85 | style: None, | |
| 86 | format: Default::default(), | |
| 87 | content: ParagraphContent::Text(content), | |
| 88 | lists: Vec::new(), | |
| 89 | tags: Vec::new(), | |
| 90 | media: Default::default(), | |
| 91 | collapsed: false, | |
| 92 | }], | |
| 93 | unsupported: Vec::new(), | |
| 94 | }; | |
| 54 | let paragraph = ops::paragraph(text); | |
| 55 | let id = paragraph.text().unwrap().id; | |
| 95 | 56 | let at = page |
| 96 | 57 | .objects |
| 97 | 58 | .iter() |
| 98 | 59 | .position(|object| matches!(object, PageObject::Title(_))) |
| 99 | 60 | .unwrap_or(page.objects.len()); |
| 100 | page.objects.insert(at, PageObject::Outline(outline)); | |
| 61 | page.objects | |
| 62 | .insert(at, ops::outline(36.0, 36.0, vec![paragraph])); | |
| 101 | 63 | id |
| 102 | 64 | } |
| 103 | 65 |
crates/onestore/Cargo.toml+6-9| ... | ... | @@ -4,9 +4,6 @@ version = "0.1.0" |
| 4 | 4 | edition = "2024" |
| 5 | 5 | publish = false |
| 6 | 6 | |
| 7 | [features] | |
| 8 | protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] | |
| 9 | ||
| 10 | 7 | [dependencies] |
| 11 | 8 | base64 = "0.22.1" |
| 12 | 9 | bumpalo = "3.20.3" |
| ... | ... | @@ -14,12 +11,12 @@ serde_json = "1.0.151" |
| 14 | 11 | md5 = "0.8.1" |
| 15 | 12 | getrandom = "0.4.3" |
| 16 | 13 | serde = { version = "1.0.229", features = ["derive"] } |
| 17 | aes = { version = "0.9.3", features = ["zeroize"], optional = true } | |
| 18 | cbc = { version = "0.2.1", features = ["zeroize"], optional = true } | |
| 19 | roxmltree = { version = "0.21.1", optional = true } | |
| 20 | sha1 = { version = "0.11.0", features = ["zeroize"], optional = true } | |
| 21 | subtle = { version = "2.6.1", optional = true } | |
| 22 | zeroize = { version = "1.9.0", optional = true } | |
| 14 | aes = { version = "0.9.3", features = ["zeroize"] } | |
| 15 | cbc = { version = "0.2.1", features = ["zeroize"] } | |
| 16 | roxmltree = "0.21.1" | |
| 17 | sha1 = { version = "0.11.0", features = ["zeroize"] } | |
| 18 | subtle = "2.6.1" | |
| 19 | zeroize = "1.9.0" | |
| 23 | 20 | |
| 24 | 21 | [target.'cfg(target_os = "macos")'.dependencies] |
| 25 | 22 | nix = { version = "0.31.3", default-features = false, features = ["fs"] } |
crates/onestore/README.md+41-29| ... | ... | @@ -47,7 +47,9 @@ harness also accepts `--client-profile release`. |
| 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 | 49 | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points with each point's pen pressure, a moved drawing offset by its position, a highlighter's raster operation, a drawn shape's kind and anchors) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | |
| 50 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | |
| 50 | | `protected::Key`, `Section::unlock` | Open a password-protected section's key with its password (`Key::open`), or make one for a new password with OneNote 2010's encryption data (`Key::new`); keep the section open under it, decoding every object and payload, and seal edits under it as OneNote does (a fresh IV per object) | | |
| 51 | | `protected::rekey` | Set, change or remove a section's password as OneNote 2010 does: the section written anew under fresh file, space and payload identities, each space keeping its labelled revisions as checkpoints | | |
| 52 | | `protected::UnlockedSection` | A protected section's labelled revisions decoded into a `Document` for inspection and export, by password or `Key`; cleared on drop, while strings and exports made from it are the caller's | | |
| 51 | 53 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 52 | 54 | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | |
| 53 | 55 | | `PageEdit`, `SectionOp::Pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | |
| ... | ... | @@ -55,6 +57,7 @@ harness also accepts `--client-profile release`. |
| 55 | 57 | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | |
| 56 | 58 | | `TocEdit`, `edit_table_of_contents` | Add, rename, order and remove a table of contents' section and group entries, and colour the notebook, as one revision's `Transaction`; a section's colour is `SectionOp::Color` in its own metadata | |
| 57 | 59 | | `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter | |
| 60 | | `place_image`, `reidentify` | The same on an image held whole, or outside any notebook; and a new file identity and version, as OneNote's Save As copies and Unpack Notebook give (`corpus/notebook-package`) | | |
| 58 | 61 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | 62 | | `PageOp::Style`, `PageOp::Restyle`, `op::restyle` | Give a paragraph a paragraph style (with its NextStyle, as OneNote 2010 writes its headings), or move every paragraph of a style to a new style object of the same name; style objects are read-only, and run or paragraph values equal to what the old style gave are cleared so they follow the new one. `op::restyle(page, sheet)` brings a page's named styles to a sheet of definitions, joining styles that share a name | |
| 60 | 63 | | `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width, the position of a file or ink drawing on the page, or a paragraph's saved expansion default, preserving identities and content | |
| ... | ... | @@ -68,7 +71,8 @@ harness also accepts `--client-profile release`. |
| 68 | 71 | | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | |
| 69 | 72 | | `read_file` | Read a snapshot under whole-file exclusion | |
| 70 | 73 | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | |
| 71 | | `CommitIo`, `confirm`, `confirm_file` | Supply another storage backend with equivalent exclusion and ordered durability; check that a stamp still holds | | |
| 74 | | `CommitIo`, `confirm`, `confirm_file`, `Stamp::check` | Supply another storage backend with equivalent exclusion and ordered durability; check that a stamp still holds | | |
| 75 | | `supersede_file` | Put a file written anew (`protected::rekey`) in place of a section under the exclusion `commit_file` takes, while its stamp holds; on Windows the old file goes aside first, as OneNote's maintenance does | | |
| 72 | 76 | |
| 73 | 77 | Edits enter a kept-open `Section` as ops and leave as one appended revision per |
| 74 | 78 | changed space; only section and page creation, imports and opening files handle |
| ... | ... | @@ -78,14 +82,12 @@ navigation titles update in the same transaction; unsupported fields, |
| 78 | 82 | protected objects and split surrogate pairs are |
| 79 | 83 | rejected before writing. Appended snapshots cap revision dependency depth at 512 |
| 80 | 84 | while retaining historical revisions. TOC snapshots can remap encoded CompactIDs |
| 81 | without changing their resolved references. Password-protected | |
| 82 | sections retain their encrypted structure and payloads. With the optional | |
| 83 | `protected` feature, `protected::UnlockedSection` opens native OneNote 2010 | |
| 84 | AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect | |
| 85 | passwords, unsupported protection profiles and work-limit failures remain distinct. | |
| 86 | The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them | |
| 87 | under the section's key (fresh IV per object, payloads under the file IV); `Section` | |
| 88 | rejects protected sections. | |
| 85 | without changing their resolved references. A password-protected section opens with | |
| 86 | `Section::unlock` under the `Key` its password opens, and every revision it seals names | |
| 87 | the key (MS-ONESTORE 2.5.19 asks that of each revision; OneNote names it only in those | |
| 88 | without a dependency, and reads both). `Section::open` refuses one. Incorrect | |
| 89 | passwords, unsupported protection profiles and work-limit failures remain distinct | |
| 90 | (`protected::Error`). | |
| 89 | 91 | `PageCreation::new` appends, or inserts before the first page space of an existing |
| 90 | 92 | series. `Some("")` creates an empty title field; `None` omits the title node. |
| 91 | 93 | `dated(date, time)` gives the title OneNote 2010's date and time fields showing that |
| ... | ... | @@ -331,34 +333,44 @@ a dedicated loopback test session; its control JSON selects the successful respo |
| 331 | 333 | and occurrence to withhold. The captured trace and result files are the regression |
| 332 | 334 | oracle; replaying the native experiments requires the supplied Windows/share setup. |
| 333 | 335 | |
| 334 | ## Protected inspection | |
| 336 | ## Password-protected sections | |
| 337 | ||
| 338 | OneNote 2010 wraps an AES-128 key in Office's Agile password encryption (MS-OFFCRYPTO: | |
| 339 | SHA-1 of a 16-byte salt and the UTF-16LE password, then 100,000 rounds of SHA-1 over the | |
| 340 | round number and the hash; three block keys decrypt, with the salt as IV, the verifier | |
| 341 | input, its SHA-1 zero-padded to 32 bytes, and the key). The XML sits after the words | |
| 342 | `3, length, 16, length - 16` and the version `4.4` with flags `0x40`, inside the | |
| 343 | encryption-data container every revision names. Each property object is stored as its | |
| 344 | reference streams, a length, a random IV and the CBC encryption of a padding count, the | |
| 345 | property bytes and random padding; read-only objects hash the clear bytes zero-padded to | |
| 346 | 8 bytes. Payloads are their length and bytes, randomly padded, under CBC with the IV | |
| 347 | SHA-1(key data salt, block 0). Setting, changing or removing a password writes the section | |
| 348 | anew, as OneNote does, under fresh identities so that no cache confuses the old file's | |
| 349 | objects or payloads with the new ones (`corpus/protected-sections`). | |
| 335 | 350 | |
| 336 | 351 | ```rust,no_run |
| 337 | # #[cfg(feature = "protected")] | |
| 338 | # fn example() { | |
| 339 | use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}}; | |
| 340 | # fn inspect(bytes: &[u8], password: &str) -> Result<(), Box<dyn std::error::Error>> { | |
| 341 | let store = Store::parse(bytes)?; | |
| 342 | let index = RevisionIndex::parse(&store)?; | |
| 343 | let unlocked = UnlockedSection::open(&index, password, Limits::default())?; | |
| 344 | let document = unlocked.document()?; | |
| 345 | assert!(!document.pages()?.is_empty()); | |
| 346 | drop(document); | |
| 347 | drop(unlocked); | |
| 352 | use onestore::{Arena, Section, protected::{Key, rekey}}; | |
| 353 | # fn example(image: Vec<u8>, password: &str) -> Result<(), Box<dyn std::error::Error>> { | |
| 354 | let key = Key::open(&image, password)?; | |
| 355 | let arena = Arena::default(); | |
| 356 | let mut section = Section::unlock(&arena, image.clone(), &key)?; | |
| 357 | assert!(!section.pages()?.is_empty()); | |
| 358 | let changed = rekey(&image, Some(&key), Some(&Key::new("another password")?))?; | |
| 359 | # drop(changed); | |
| 348 | 360 | # Ok(()) } |
| 349 | # } | |
| 350 | 361 | ``` |
| 351 | 362 | |
| 352 | This example requires `features = ["protected"]`. The owner retains no password; | |
| 353 | its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed | |
| 354 | strings, serialized models and exports have their own lifetimes. These copies are | |
| 355 | plaintext, and dropping the unlock owner does not clear them. CBC has no general | |
| 363 | A `Key` holds no password; its key is cleared when its last clone drops. A `Section` | |
| 364 | decodes into its `Arena`, which is not cleared; drop both when the section locks. | |
| 365 | `UnlockedSection` owns its decoded buffers and clears them on drop; the views it lends | |
| 366 | cannot outlive it, while copies of parsed strings, serialized models and exports are | |
| 367 | plaintext with lifetimes of their own. CBC has no general | |
| 356 | 368 | ciphertext-authentication guarantee; native read-only hashes and model validation |
| 357 | 369 | check the corresponding structure. Internal payloads are decoded; external payload |
| 358 | 370 | references remain references, and their protected decoding is not implemented. |
| 359 | 371 | |
| 360 | For a deliberate plaintext diagnostic export, build the notebook exporter with | |
| 361 | `--features protected` and pass `--password-file PATH` after the source and optional | |
| 372 | For a deliberate plaintext diagnostic export, run the notebook exporter | |
| 373 | (`examples/document`) with `--password-file PATH` after the source and optional | |
| 362 | 374 | new output directory. The file contains exact UTF-8 password bytes; no newline is |
| 363 | 375 | removed or Unicode normalization applied. The exporter creates protected exports |
| 364 | 376 | under an owner-only directory on Unix and reports protected external payloads as |
crates/onestore/examples/scratch_dump.rs deleted-53| ... | ... | @@ -1,53 +0,0 @@ |
| 1 | use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; | |
| 2 | use std::{env, fs}; | |
| 3 | ||
| 4 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 5 | let want: Vec<u32> = env::var("IDS") | |
| 6 | .unwrap_or_default() | |
| 7 | .split(',') | |
| 8 | .filter(|s| !s.is_empty()) | |
| 9 | .map(|s| u32::from_str_radix(s.trim_start_matches("0x"), 16).unwrap()) | |
| 10 | .collect(); | |
| 11 | for path in env::args_os().skip(1) { | |
| 12 | let bytes = fs::read(&path)?; | |
| 13 | let store = Store::parse(&bytes)?; | |
| 14 | let index = RevisionIndex::parse(&store)?; | |
| 15 | println!("== {path:?}"); | |
| 16 | for space in index.spaces.keys() { | |
| 17 | let Ok(resolved) = index.resolve_active(*space) else { continue }; | |
| 18 | for (id, object) in &resolved.objects { | |
| 19 | let ObjectData::Properties(data) = object.data else { continue }; | |
| 20 | let sets = PropertySets::parse(data)?; | |
| 21 | let root = &sets.sets[0]; | |
| 22 | if !want.is_empty() && !root.iter().any(|p| want.contains(&p.id)) { | |
| 23 | continue; | |
| 24 | } | |
| 25 | if env::var("LINE").is_ok() { | |
| 26 | let get = |want: u32| root.iter().find(|p| p.id == want).and_then(|p| match p.value { Value::Bytes(b) => Some(b), _ => None }); | |
| 27 | let text = get(0x1c003498).map(|b| String::from_utf8_lossy(b).into_owned()).or_else(|| get(0x1c001c22).map(|b| String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>()))).unwrap_or_default(); | |
| 28 | let ids: Vec<String> = root.iter().map(|p| format!("{:x}", p.id)).collect(); | |
| 29 | println!("{:#x} cc8={:02x?} media={} text={text:?} ids={}", object.jcid, get(0x1c001cc8), get(0x1c001c98).is_some(), ids.join(",")); | |
| 30 | continue; | |
| 31 | } | |
| 32 | println!("{space:?} {id:?} jcid {:#x}", object.jcid); | |
| 33 | for p in root { | |
| 34 | match p.value { | |
| 35 | Value::Bytes(b) => { | |
| 36 | let text = if p.id == 0x1c001c22 { | |
| 37 | String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>()) | |
| 38 | } else if p.id == 0x1c003498 { | |
| 39 | String::from_utf8_lossy(b).into_owned() | |
| 40 | } else { | |
| 41 | String::new() | |
| 42 | }; | |
| 43 | println!(" {:#010x} {:02x?} {text}", p.id, &b[..b.len().min(48)]) | |
| 44 | } | |
| 45 | Value::NoData => println!(" {:#010x} -", p.id), | |
| 46 | ref v => println!(" {:#010x} {v:?}", p.id), | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | Ok(()) | |
| 53 | } |
crates/onestore/src/active.rs+5-6| ... | ... | @@ -4,11 +4,7 @@ use crate::{ |
| 4 | 4 | write::{Commit, LiveRevision, PropertyObject, declared, differing}, |
| 5 | 5 | }; |
| 6 | 6 | #[cfg(test)] |
| 7 | use crate::{ | |
| 8 | RevisionIndex, Store, | |
| 9 | document::{Document, Space}, | |
| 10 | write::chain_depth, | |
| 11 | }; | |
| 7 | use crate::{RevisionIndex, Store, document::Document, write::chain_depth}; | |
| 12 | 8 | use bumpalo::Bump; |
| 13 | 9 | use std::{ |
| 14 | 10 | collections::{BTreeMap, BTreeSet}, |
| ... | ... | @@ -50,7 +46,10 @@ impl<'a> ActivePage<'a> { |
| 50 | 46 | let view = document |
| 51 | 47 | .spaces |
| 52 | 48 | .remove(&space) |
| 53 | .and_then(Space::into_active) | |
| 49 | .and_then(|mut space| { | |
| 50 | let active = *space.contexts.get(&ExGuid::default())?; | |
| 51 | space.revisions.remove(&active) | |
| 52 | }) | |
| 54 | 53 | .ok_or(Error { |
| 55 | 54 | offset: 0, |
| 56 | 55 | message: "The active page is unavailable", |
crates/onestore/src/commit.rs+53-4| ... | ... | @@ -65,9 +65,17 @@ impl FileIo { |
| 65 | 65 | } |
| 66 | 66 | })?); |
| 67 | 67 | #[cfg(unix)] |
| 68 | let file = options.open(path)?; | |
| 68 | let file = options.open(path.as_ref())?; | |
| 69 | 69 | #[cfg(all(unix, not(target_os = "macos")))] |
| 70 | file.try_lock()?; | |
| 70 | { | |
| 71 | use std::os::unix::fs::MetadataExt; | |
| 72 | file.try_lock()?; | |
| 73 | // Locked after it was opened, the file may since have been superseded. | |
| 74 | let (open, named) = (file.metadata()?, std::fs::metadata(path)?); | |
| 75 | if (open.dev(), open.ino()) != (named.dev(), named.ino()) { | |
| 76 | return Err(ErrorKind::ResourceBusy.into()); | |
| 77 | } | |
| 78 | } | |
| 71 | 79 | Ok(Self { |
| 72 | 80 | #[cfg(windows)] |
| 73 | 81 | locks: onenote_locks(&file, write)?, |
| ... | ... | @@ -255,6 +263,45 @@ pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitEr |
| 255 | 263 | io.finish(result) |
| 256 | 264 | } |
| 257 | 265 | |
| 266 | /// Puts the file at `with` in the place of the file at `path`, provided `path` still has | |
| 267 | /// `base`'s stamp, under the exclusion `commit_file` takes: the whole-image write OneNote 2010 | |
| 268 | /// makes when it writes a section anew. | |
| 269 | #[cfg(any(unix, windows))] | |
| 270 | pub fn supersede_file( | |
| 271 | path: impl AsRef<Path>, | |
| 272 | base: &Stamp, | |
| 273 | with: impl AsRef<Path>, | |
| 274 | ) -> Result<(), CommitError> { | |
| 275 | let (path, with) = (path.as_ref(), with.as_ref()); | |
| 276 | let failed = |state| move |error| CommitError { state, error }; | |
| 277 | // Windows renames nothing over an open file, so there the old file goes aside first and is | |
| 278 | // deleted once released, as OneNote's maintenance does. | |
| 279 | let mut aside = path.as_os_str().to_owned(); | |
| 280 | if cfg!(windows) { | |
| 281 | aside.push(".old"); | |
| 282 | } | |
| 283 | let aside = std::path::PathBuf::from(aside); | |
| 284 | let mut io = FileIo::open(path, true).map_err(failed(CommitState::NotCommitted))?; | |
| 285 | let result = base | |
| 286 | .check(&mut io) | |
| 287 | .and_then(|()| std::fs::rename(path, &aside)) | |
| 288 | .map_err(failed(CommitState::NotCommitted)) | |
| 289 | .and_then(|()| { | |
| 290 | std::fs::rename(with, path).map_err(|error| CommitError { | |
| 291 | state: match std::fs::rename(&aside, path) { | |
| 292 | Ok(()) => CommitState::NotCommitted, | |
| 293 | Err(_) => CommitState::Unknown, | |
| 294 | }, | |
| 295 | error, | |
| 296 | }) | |
| 297 | }); | |
| 298 | io.finish(result)?; | |
| 299 | if aside != path { | |
| 300 | std::fs::remove_file(aside).map_err(failed(CommitState::Committed))?; | |
| 301 | } | |
| 302 | Ok(()) | |
| 303 | } | |
| 304 | ||
| 258 | 305 | /// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header |
| 259 | 306 | /// version metadata. No revision is added; reread before committing on `base` again. |
| 260 | 307 | /// The caller must hold OneNote-compatible exclusion and independently establish which |
| ... | ... | @@ -357,8 +404,10 @@ impl Stamp { |
| 357 | 404 | }) |
| 358 | 405 | } |
| 359 | 406 | |
| 360 | /// Reads the header and probes the length, without reading the body. | |
| 361 | fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { | |
| 407 | /// Checks that the file `io` reads still has this stamp, reading its header and probing its | |
| 408 | /// length without reading the body; `ResourceBusy` when it moved on. The caller holds | |
| 409 | /// OneNote-compatible exclusion for whatever the check guards. | |
| 410 | pub fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { | |
| 362 | 411 | let mut header = [0; 1024]; |
| 363 | 412 | crate::snapshot::read_exact( |
| 364 | 413 | &mut |offset, output| io.read_at(offset, output), |
crates/onestore/src/create.rs+56-6| ... | ... | @@ -615,6 +615,30 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 615 | 615 | } else { |
| 616 | 616 | None |
| 617 | 617 | }; |
| 618 | finish( | |
| 619 | &mut output, | |
| 620 | file_type, | |
| 621 | log, | |
| 622 | hashed_chunk, | |
| 623 | root_chunk, | |
| 624 | placement([0; 16], file_name), | |
| 625 | )?; | |
| 626 | let store = Store::parse(&output)?; | |
| 627 | RevisionIndex::parse(&store)?.validate_current()?; | |
| 628 | Ok(output) | |
| 629 | } | |
| 630 | ||
| 631 | /// Ends an image whose lists `output` holds: the transaction log committing `log`'s | |
| 632 | /// entries, then the header naming it, the root list and the hashed-chunk list. | |
| 633 | fn finish( | |
| 634 | output: &mut Vec<u8>, | |
| 635 | file_type: FileType, | |
| 636 | mut log: Vec<u8>, | |
| 637 | hashed: Option<Chunk>, | |
| 638 | root: Chunk, | |
| 639 | placement: [u8; 20], | |
| 640 | ) -> Result<()> { | |
| 641 | let is_section = file_type == FileType::Section; | |
| 618 | 642 | let checksum = if is_section { |
| 619 | 643 | !crc(u32::MAX, &log, file_type) |
| 620 | 644 | } else { |
| ... | ... | @@ -624,7 +648,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 624 | 648 | log.extend_from_slice(&checksum.to_le_bytes()); |
| 625 | 649 | log.extend_from_slice(&u64::MAX.to_le_bytes()); |
| 626 | 650 | log.extend_from_slice(&0_u32.to_le_bytes()); |
| 627 | let log_chunk = append(&mut output, &log)?; | |
| 651 | let log_chunk = append(output, &log)?; | |
| 628 | 652 | output[..16].copy_from_slice(&[ |
| 629 | 653 | 0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0, 0x29, 0x96, |
| 630 | 654 | 0xd3, |
| ... | ... | @@ -648,11 +672,11 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 648 | 672 | output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); |
| 649 | 673 | } |
| 650 | 674 | output[96..100].copy_from_slice(&1_u32.to_le_bytes()); |
| 651 | output[128..148].copy_from_slice(&placement([0; 16], file_name)); | |
| 652 | for (at, chunk) in hashed_chunk | |
| 675 | output[128..148].copy_from_slice(&placement); | |
| 676 | for (at, chunk) in hashed | |
| 653 | 677 | .map(|chunk| (148, chunk)) |
| 654 | 678 | .into_iter() |
| 655 | .chain([(160, log_chunk), (172, root_chunk)]) | |
| 679 | .chain([(160, log_chunk), (172, root)]) | |
| 656 | 680 | { |
| 657 | 681 | output[at..at + 8].copy_from_slice(&chunk.offset.to_le_bytes()); |
| 658 | 682 | output[at + 8..at + 12].copy_from_slice(&(chunk.length as u32).to_le_bytes()); |
| ... | ... | @@ -662,8 +686,34 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 662 | 686 | output[212..228].copy_from_slice(&fresh_guid()?); |
| 663 | 687 | output[228..236].copy_from_slice(&1_u64.to_le_bytes()); |
| 664 | 688 | output[236..252].copy_from_slice(&fresh_guid()?); |
| 665 | let store = Store::parse(&output)?; | |
| 666 | RevisionIndex::parse(&store)?.validate_current()?; | |
| 689 | Ok(()) | |
| 690 | } | |
| 691 | ||
| 692 | /// A new section file declaring only its root object space `root`, which has no revision | |
| 693 | /// yet, placed as `placement`: what a whole section's revisions are appended to. | |
| 694 | pub(crate) fn skeleton(root: ExGuid, placement: [u8; 20]) -> Result<Vec<u8>> { | |
| 695 | let mut output = vec![0; 1024]; | |
| 696 | let mut payload = Vec::new(); | |
| 697 | root.encode(&mut payload); | |
| 698 | let mut start = payload.clone(); | |
| 699 | start.extend_from_slice(&0_u32.to_le_bytes()); | |
| 700 | let manifest = append_list(&mut output, 16, &[node(0x14, None, &start)?])?; | |
| 701 | let space = [ | |
| 702 | node(0xc, None, &payload)?, | |
| 703 | node(0x10, Some(Reference::NodeList(manifest)), &[])?, | |
| 704 | ]; | |
| 705 | let space = append_list(&mut output, 17, &space)?; | |
| 706 | let roots = [ | |
| 707 | node(8, Some(Reference::NodeList(space)), &payload)?, | |
| 708 | node(4, None, &payload)?, | |
| 709 | ]; | |
| 710 | let roots = append_list(&mut output, 18, &roots)?; | |
| 711 | let mut log = Vec::new(); | |
| 712 | for (id, count) in [(16_u32, 1_u32), (17, 2), (18, 2)] { | |
| 713 | log.extend_from_slice(&id.to_le_bytes()); | |
| 714 | log.extend_from_slice(&count.to_le_bytes()); | |
| 715 | } | |
| 716 | finish(&mut output, FileType::Section, log, None, roots, placement)?; | |
| 667 | 717 | Ok(output) |
| 668 | 718 | } |
| 669 | 719 |
crates/onestore/src/document.rs-5| ... | ... | @@ -39,11 +39,6 @@ impl<'a> Space<'a> { |
| 39 | 39 | pub fn active(&self) -> Option<&Revision<'a>> { |
| 40 | 40 | self.revisions.get(self.contexts.get(&ExGuid::default())?) |
| 41 | 41 | } |
| 42 | ||
| 43 | pub fn into_active(mut self) -> Option<Revision<'a>> { | |
| 44 | self.revisions | |
| 45 | .remove(self.contexts.get(&ExGuid::default())?) | |
| 46 | } | |
| 47 | 42 | } |
| 48 | 43 | |
| 49 | 44 | #[derive(Debug, Clone, Serialize)] |
crates/onestore/src/identity.rs created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | //! A file's identity and place, rewritten in an image held whole: OneNote 2010 saves a | |
| 2 | //! section as a copy, and writes a package's files, outside any notebook, and gives each | |
| 3 | //! file it unpacks an identity of its own (`corpus/notebook-package`). | |
| 4 | ||
| 5 | use crate::{Error, Header, create::placement, write::fresh_guid}; | |
| 6 | ||
| 7 | type Result<T> = std::result::Result<T, Error>; | |
| 8 | ||
| 9 | /// Gives the file `image` holds a new `guidFile` and version (`guidFileVersion`, | |
| 10 | /// `nFileVersionGeneration`, `guidDenyReadFileVersion`), returning the identity. | |
| 11 | pub fn reidentify(image: &mut [u8]) -> Result<[u8; 16]> { | |
| 12 | let header = Header::parse(image)?; | |
| 13 | let identity = fresh_guid()?; | |
| 14 | let generation = header.generation.checked_add(1).ok_or(Error { | |
| 15 | offset: 228, | |
| 16 | message: "File generation counter is exhausted", | |
| 17 | })?; | |
| 18 | image[16..32].copy_from_slice(&identity); | |
| 19 | image[212..228].copy_from_slice(&fresh_guid()?); | |
| 20 | image[228..236].copy_from_slice(&generation.to_le_bytes()); | |
| 21 | image[236..252].copy_from_slice(&fresh_guid()?); | |
| 22 | Ok(identity) | |
| 23 | } | |
| 24 | ||
| 25 | /// Places the file `image` holds as `place` does a file on disk; without `at`, outside any | |
| 26 | /// notebook, its `guidAncestor` and `crcName` zero. | |
| 27 | pub fn place_image(image: &mut [u8], at: Option<([u8; 16], &str)>) -> Result<()> { | |
| 28 | Header::parse(image)?; | |
| 29 | image[128..148] | |
| 30 | .copy_from_slice(&at.map_or([0; 20], |(ancestor, name)| placement(ancestor, name))); | |
| 31 | Ok(()) | |
| 32 | } | |
| 33 | ||
| 34 | #[cfg(test)] | |
| 35 | mod tests { | |
| 36 | use super::*; | |
| 37 | ||
| 38 | #[test] | |
| 39 | fn a_file_takes_a_new_identity_and_leaves_its_notebook() { | |
| 40 | let mut image = crate::create_empty_section("A.one", None).unwrap(); | |
| 41 | let before = Header::parse(&image).unwrap(); | |
| 42 | place_image(&mut image, Some(([7; 16], "A.one"))).unwrap(); | |
| 43 | assert_eq!(Header::parse(&image).unwrap().ancestor, [7; 16]); | |
| 44 | let identity = reidentify(&mut image).unwrap(); | |
| 45 | place_image(&mut image, None).unwrap(); | |
| 46 | let after = Header::parse(&image).unwrap(); | |
| 47 | assert_eq!(after.file_id, identity); | |
| 48 | assert_ne!(after.file_id, before.file_id); | |
| 49 | assert_ne!(after.version_id, before.version_id); | |
| 50 | assert_eq!(after.generation, before.generation + 1); | |
| 51 | assert_eq!((after.ancestor, after.name_crc), ([0; 16], 0)); | |
| 52 | crate::Store::parse(&image).unwrap(); | |
| 53 | } | |
| 54 | } |
crates/onestore/src/lib.rs+3-2| ... | ... | @@ -10,6 +10,7 @@ mod edit; |
| 10 | 10 | mod files; |
| 11 | 11 | mod flush; |
| 12 | 12 | mod formatting; |
| 13 | mod identity; | |
| 13 | 14 | mod insertion; |
| 14 | 15 | mod objects; |
| 15 | 16 | pub mod op; |
| ... | ... | @@ -18,7 +19,6 @@ pub mod page; |
| 18 | 19 | mod pages; |
| 19 | 20 | mod paragraph; |
| 20 | 21 | mod properties; |
| 21 | #[cfg(feature = "protected")] | |
| 22 | 22 | pub mod protected; |
| 23 | 23 | mod revisions; |
| 24 | 24 | mod section; |
| ... | ... | @@ -33,10 +33,11 @@ mod write; |
| 33 | 33 | |
| 34 | 34 | pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place}; |
| 35 | 35 | #[cfg(any(unix, windows))] |
| 36 | pub use commit::{confirm_file, place_file, read_file, read_file_limited}; | |
| 36 | pub use commit::{confirm_file, place_file, read_file, read_file_limited, supersede_file}; | |
| 37 | 37 | pub use create::{create_empty_section, create_section, create_table_of_contents}; |
| 38 | 38 | pub use files::FileDataReference; |
| 39 | 39 | pub use formatting::{FONT_SIZES, TextAttribute}; |
| 40 | pub use identity::{place_image, reidentify}; | |
| 40 | 41 | pub(crate) use insertion::Insertion; |
| 41 | 42 | pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; |
| 42 | 43 | pub use outline::OutlineEdit; |
crates/onestore/src/objects.rs+10-1| ... | ... | @@ -190,11 +190,20 @@ impl<'a> Cursor<'a> { |
| 190 | 190 | |
| 191 | 191 | impl<'a> RevisionIndex<'a> { |
| 192 | 192 | pub fn validate_current(&self) -> Result<()> { |
| 193 | self.validate_with(|space, rid| self.resolve(space, rid)) | |
| 194 | } | |
| 195 | ||
| 196 | /// `validate_current`, each revision read through `resolve`, as a protected section's | |
| 197 | /// decoded. | |
| 198 | pub(crate) fn validate_with<'r>( | |
| 199 | &self, | |
| 200 | resolve: impl Fn(ExGuid, ExGuid) -> Result<ResolvedRevision<'r>>, | |
| 201 | ) -> Result<()> { | |
| 193 | 202 | let mut edges = BTreeMap::new(); |
| 194 | 203 | let mut incoming = BTreeMap::<_, usize>::new(); |
| 195 | 204 | for (osid, space) in &self.spaces { |
| 196 | 205 | for rid in space.labels.values().copied().collect::<BTreeSet<_>>() { |
| 197 | let revision = self.resolve(*osid, rid)?; | |
| 206 | let revision = resolve(*osid, rid)?; | |
| 198 | 207 | let mut targets = BTreeSet::new(); |
| 199 | 208 | for oid in revision.reachable()? { |
| 200 | 209 | let references = revision.objects[&oid].references()?; |
crates/onestore/src/op/content.rs+1-1| ... | ... | @@ -16,7 +16,7 @@ fn invalid(message: &'static str) -> Error { |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | /// The `<ifndf>{GUID}` form a file-data declaration uses to name a payload in the file-data store. |
| 19 | fn payload_reference(guid: [u8; 16]) -> String { | |
| 19 | pub(crate) fn payload_reference(guid: [u8; 16]) -> String { | |
| 20 | 20 | let id = ExGuid { guid, n: 0 }.to_string(); |
| 21 | 21 | format!("<ifndf>{}", id.split(',').next().unwrap()) |
| 22 | 22 | } |
crates/onestore/src/op/lower.rs+1| ... | ... | @@ -44,6 +44,7 @@ pub fn lower( |
| 44 | 44 | identity: None, |
| 45 | 45 | created: None, |
| 46 | 46 | margin_origin: [0.0; 2], |
| 47 | rtl: false, | |
| 47 | 48 | color: None, |
| 48 | 49 | rule_lines: None, |
| 49 | 50 | objects: vec![PageObject::Outline(Outline { |
crates/onestore/src/page/mod.rs+5| ... | ... | @@ -159,6 +159,9 @@ pub struct Page { |
| 159 | 159 | /// FILETIME ticks from the page's TopologyCreationTimeStamp. |
| 160 | 160 | pub created: Option<u64>, |
| 161 | 161 | pub margin_origin: [f32; 2], |
| 162 | /// Right-to-left page direction, whose positions keep the left-to-right frame. | |
| 163 | #[serde(default)] | |
| 164 | pub rtl: bool, | |
| 162 | 165 | /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". |
| 163 | 166 | #[serde(default)] |
| 164 | 167 | pub color: Option<u32>, |
| ... | ... | @@ -814,6 +817,7 @@ impl Page { |
| 814 | 817 | let Kind::Page { |
| 815 | 818 | margin_origin_x, |
| 816 | 819 | margin_origin_y, |
| 820 | rtl, | |
| 817 | 821 | .. |
| 818 | 822 | } = &root.kind |
| 819 | 823 | else { |
| ... | ... | @@ -845,6 +849,7 @@ impl Page { |
| 845 | 849 | margin_origin_x.unwrap_or(1.0_f32 * 36.0), |
| 846 | 850 | margin_origin_y.unwrap_or(0.4_f32 * 36.0), |
| 847 | 851 | ], |
| 852 | rtl: rtl.unwrap_or(false), | |
| 848 | 853 | color: root |
| 849 | 854 | .extra |
| 850 | 855 | .first() |
crates/onestore/src/protected/crypto.rs+122-40| ... | ... | @@ -89,6 +89,48 @@ fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) { |
| 89 | 89 | .expect("block aligned"); |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | /// Password-hash rounds OneNote 2010 writes. | |
| 93 | const SPINS: u32 = 100_000; | |
| 94 | /// MS-OFFCRYPTO's block keys for the verifier input, its hash and the wrapped key. | |
| 95 | const VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79]; | |
| 96 | const VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e]; | |
| 97 | const KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]; | |
| 98 | ||
| 99 | /// The iterated password hash: SHA-1 of salt and UTF-16LE password, then `count` rounds of | |
| 100 | /// SHA-1 over the round number and the previous hash. | |
| 101 | fn seed(salt: &[u8; 16], password: &str, count: u32) -> Zeroizing<[u8; 20]> { | |
| 102 | let mut hash = Sha1::new(); | |
| 103 | hash.update(salt); | |
| 104 | for word in password.encode_utf16() { | |
| 105 | hash.update(word.to_le_bytes()); | |
| 106 | } | |
| 107 | let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); | |
| 108 | for index in 0..count { | |
| 109 | let mut hash = Sha1::new(); | |
| 110 | hash.update(index.to_le_bytes()); | |
| 111 | hash.update(seed.as_ref()); | |
| 112 | *seed = hash.finalize().into(); | |
| 113 | } | |
| 114 | seed | |
| 115 | } | |
| 116 | ||
| 117 | /// The AES key a block key derives from the password hash. | |
| 118 | fn block(seed: &[u8; 20], label: [u8; 8]) -> Zeroizing<[u8; 16]> { | |
| 119 | let mut hash = Sha1::new(); | |
| 120 | hash.update(seed); | |
| 121 | hash.update(label); | |
| 122 | let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); | |
| 123 | Zeroizing::new(derived[..16].try_into().unwrap()) | |
| 124 | } | |
| 125 | ||
| 126 | /// The IV of payloads: SHA-1 of the key data's salt and block 0. | |
| 127 | fn file_iv(salt: &[u8; 16]) -> [u8; 16] { | |
| 128 | let mut hash = Sha1::new(); | |
| 129 | hash.update(salt); | |
| 130 | hash.update(0_u32.to_le_bytes()); | |
| 131 | hash.finalize()[..16].try_into().unwrap() | |
| 132 | } | |
| 133 | ||
| 92 | 134 | impl Key { |
| 93 | 135 | pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> { |
| 94 | 136 | if data.len() > 65536 || password.len() > 65536 { |
| ... | ... | @@ -146,47 +188,83 @@ impl Key { |
| 146 | 188 | let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?); |
| 147 | 189 | let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?); |
| 148 | 190 | let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?); |
| 149 | let mut hash = Sha1::new(); | |
| 150 | hash.update(salt); | |
| 151 | for word in password.encode_utf16() { | |
| 152 | hash.update(word.to_le_bytes()); | |
| 153 | } | |
| 154 | let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); | |
| 155 | for index in 0..count { | |
| 156 | let mut hash = Sha1::new(); | |
| 157 | hash.update(index.to_le_bytes()); | |
| 158 | hash.update(seed.as_ref()); | |
| 159 | *seed = hash.finalize().into(); | |
| 160 | } | |
| 191 | let seed = seed(&salt, password, count); | |
| 161 | 192 | for (label, bytes) in [ |
| 162 | ( | |
| 163 | [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79], | |
| 164 | verifier.as_mut_slice(), | |
| 165 | ), | |
| 166 | ( | |
| 167 | [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e], | |
| 168 | expected.as_mut_slice(), | |
| 169 | ), | |
| 170 | ( | |
| 171 | [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6], | |
| 172 | value.as_mut_slice(), | |
| 173 | ), | |
| 193 | (VERIFIER_INPUT, verifier.as_mut_slice()), | |
| 194 | (VERIFIER_VALUE, expected.as_mut_slice()), | |
| 195 | (KEY_VALUE, value.as_mut_slice()), | |
| 174 | 196 | ] { |
| 175 | let mut hash = Sha1::new(); | |
| 176 | hash.update(seed.as_ref()); | |
| 177 | hash.update(label); | |
| 178 | let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); | |
| 179 | decrypt(derived[..16].try_into().unwrap(), &salt, bytes)?; | |
| 197 | decrypt(&block(&seed, label), &salt, bytes)?; | |
| 180 | 198 | } |
| 181 | 199 | let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice()))); |
| 182 | 200 | if !bool::from(actual.as_slice().ct_eq(&expected[..20])) { |
| 183 | 201 | return Err(Error::PasswordMismatch); |
| 184 | 202 | } |
| 185 | let mut hash = Sha1::new(); | |
| 186 | hash.update(decoded::<16>(data_key, "saltValue")?); | |
| 187 | hash.update(0_u32.to_le_bytes()); | |
| 188 | let file_iv = hash.finalize()[..16].try_into().unwrap(); | |
| 189 | Ok(Self { value, file_iv }) | |
| 203 | Ok(Self { | |
| 204 | value, | |
| 205 | file_iv: file_iv(&decoded::<16>(data_key, "saltValue")?), | |
| 206 | }) | |
| 207 | } | |
| 208 | ||
| 209 | /// A fresh key for `password` and the encryption data that opens it, as OneNote 2010 | |
| 210 | /// writes them: random salts, key and verifier, 100,000 SHA-1 rounds, no integrity block. | |
| 211 | pub(super) fn create(password: &str) -> Result<(Self, Vec<u8>)> { | |
| 212 | if password.len() > 65536 { | |
| 213 | return Err(Error::Limit); | |
| 214 | } | |
| 215 | let random = |bytes: &mut [u8]| { | |
| 216 | getrandom::fill(bytes).map_err(|_| invalid("System random source failed")) | |
| 217 | }; | |
| 218 | let (mut data_salt, mut salt) = ([0; 16], [0; 16]); | |
| 219 | let mut value = Zeroizing::new([0; 16]); | |
| 220 | let mut verifier = Zeroizing::new([0; 16]); | |
| 221 | random(&mut data_salt)?; | |
| 222 | random(&mut salt)?; | |
| 223 | random(value.as_mut_slice())?; | |
| 224 | random(verifier.as_mut_slice())?; | |
| 225 | let seed = seed(&salt, password, SPINS); | |
| 226 | let mut hash = Zeroizing::new([0; 32]); | |
| 227 | hash[..20].copy_from_slice(&Sha1::digest(verifier.as_slice())); | |
| 228 | let mut wrapped_value = *value; | |
| 229 | let mut wrapped_verifier = *verifier; | |
| 230 | let mut wrapped_hash = *hash; | |
| 231 | encrypt(&block(&seed, VERIFIER_INPUT), &salt, &mut wrapped_verifier); | |
| 232 | encrypt(&block(&seed, VERIFIER_VALUE), &salt, &mut wrapped_hash); | |
| 233 | encrypt(&block(&seed, KEY_VALUE), &salt, &mut wrapped_value); | |
| 234 | let profile = r#"saltSize="16" blockSize="16" keyBits="128" hashSize="20" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="SHA1""#; | |
| 235 | let xml = format!( | |
| 236 | "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\ | |
| 237 | <encryption xmlns=\"{NS}\" xmlns:p=\"{PASSWORD_NS}\">\ | |
| 238 | <keyData {profile} saltValue=\"{}\"/>\ | |
| 239 | <keyEncryptors><keyEncryptor uri=\"{PASSWORD_NS}\">\ | |
| 240 | <p:encryptedKey spinCount=\"{SPINS}\" {profile} saltValue=\"{}\" \ | |
| 241 | encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" \ | |
| 242 | encryptedKeyValue=\"{}\"/></keyEncryptor></keyEncryptors></encryption>", | |
| 243 | STANDARD.encode(data_salt), | |
| 244 | STANDARD.encode(salt), | |
| 245 | STANDARD.encode(wrapped_verifier), | |
| 246 | STANDARD.encode(wrapped_hash), | |
| 247 | STANDARD.encode(wrapped_value), | |
| 248 | ); | |
| 249 | let length = u32::try_from(24 + xml.len()).map_err(|_| Error::Limit)?; | |
| 250 | let mut data = Vec::with_capacity(length as usize); | |
| 251 | for word in [3, length, 16, length - 16] { | |
| 252 | data.extend_from_slice(&word.to_le_bytes()); | |
| 253 | } | |
| 254 | data.extend_from_slice(&[4, 0, 4, 0, 64, 0, 0, 0]); | |
| 255 | data.extend_from_slice(xml.as_bytes()); | |
| 256 | Ok(( | |
| 257 | Self { | |
| 258 | value, | |
| 259 | file_iv: file_iv(&data_salt), | |
| 260 | }, | |
| 261 | data, | |
| 262 | )) | |
| 263 | } | |
| 264 | ||
| 265 | /// The AES key the section's objects and payloads are encrypted under. | |
| 266 | pub(super) fn value(&self) -> &[u8; 16] { | |
| 267 | &self.value | |
| 190 | 268 | } |
| 191 | 269 | |
| 192 | 270 | pub(super) fn property(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> { |
| ... | ... | @@ -246,17 +324,21 @@ impl Key { |
| 246 | 324 | Ok(output) |
| 247 | 325 | } |
| 248 | 326 | |
| 249 | /// The stored form of a file payload, the inverse of `file`. | |
| 250 | pub(super) fn seal_file(&self, clear: &[u8]) -> Vec<u8> { | |
| 327 | /// The stored form of a file payload, the inverse of `file`: its length, the bytes and | |
| 328 | /// random padding to the block, as OneNote pads. | |
| 329 | pub(super) fn seal_file(&self, clear: &[u8]) -> Result<Vec<u8>> { | |
| 251 | 330 | if clear.is_empty() { |
| 252 | return Vec::new(); | |
| 331 | return Ok(Vec::new()); | |
| 253 | 332 | } |
| 254 | let mut output = Vec::with_capacity((8 + clear.len()).next_multiple_of(16)); | |
| 333 | let length = (8 + clear.len()).next_multiple_of(16); | |
| 334 | let mut output = Vec::with_capacity(length); | |
| 255 | 335 | output.extend_from_slice(&(clear.len() as u64).to_le_bytes()); |
| 256 | 336 | output.extend_from_slice(clear); |
| 257 | output.resize(output.len().next_multiple_of(16), 0); | |
| 337 | let end = output.len(); | |
| 338 | output.resize(length, 0); | |
| 339 | getrandom::fill(&mut output[end..]).map_err(|_| invalid("System random source failed"))?; | |
| 258 | 340 | encrypt(&self.value, &self.file_iv, &mut output); |
| 259 | output | |
| 341 | Ok(output) | |
| 260 | 342 | } |
| 261 | 343 | |
| 262 | 344 | pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> { |
crates/onestore/src/protected/mod.rs+370-254| ... | ... | @@ -1,24 +1,26 @@ |
| 1 | //! Explicit, in-memory opening of native OneNote 2010 protected sections. | |
| 2 | //! | |
| 3 | //! AES-128/CBC and SHA-1 Agile password wrappers are supported. Unknown wrappers | |
| 4 | //! remain opaque through the ordinary storage/document APIs. CBC provides no | |
| 5 | //! general ciphertext authentication; native read-only hashes and model checks | |
| 6 | //! detect structural inconsistencies, not arbitrary changes to all content. | |
| 1 | //! OneNote 2010's password-protected sections: an Office Agile password wrapper | |
| 2 | //! (MS-OFFCRYPTO; SHA-1, 100,000 rounds) around an AES-128 key that encrypts every | |
| 3 | //! property object (CBC, an IV of its own) and payload (CBC, one IV from the key data's | |
| 4 | //! salt). Unknown wrappers stay opaque. CBC authenticates nothing; read-only hashes and | |
| 5 | //! model checks catch structural damage, not every change to content. | |
| 7 | 6 | |
| 8 | 7 | mod crypto; |
| 9 | 8 | |
| 10 | 9 | use crate::{ |
| 11 | ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Transaction, | |
| 10 | Chunk, ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Store, | |
| 12 | 11 | document::Document, |
| 13 | op::{Edit, Op, OpError}, | |
| 14 | 12 | }; |
| 13 | use bumpalo::Bump; | |
| 15 | 14 | use std::{ |
| 15 | cell::RefCell, | |
| 16 | 16 | collections::{BTreeMap, BTreeSet}, |
| 17 | 17 | fmt, |
| 18 | sync::Arc, | |
| 18 | 19 | }; |
| 19 | 20 | use zeroize::Zeroizing; |
| 20 | 21 | |
| 21 | 22 | type Result<T> = std::result::Result<T, Error>; |
| 23 | type Format<T> = std::result::Result<T, crate::Error>; | |
| 22 | 24 | |
| 23 | 25 | #[derive(Debug)] |
| 24 | 26 | pub enum Error { |
| ... | ... | @@ -34,15 +36,34 @@ impl From<crate::Error> for Error { |
| 34 | 36 | } |
| 35 | 37 | } |
| 36 | 38 | |
| 39 | impl Error { | |
| 40 | fn message(&self) -> &'static str { | |
| 41 | match self { | |
| 42 | Self::PasswordMismatch => "The password did not match the section verifier", | |
| 43 | Self::Unsupported => "This protection format is not supported", | |
| 44 | Self::Limit => "Opening the protected section exceeded its work limit", | |
| 45 | Self::Invalid(error) => error.message, | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | impl From<Error> for crate::Error { | |
| 51 | fn from(error: Error) -> Self { | |
| 52 | match error { | |
| 53 | Error::Invalid(error) => error, | |
| 54 | other => crate::Error { | |
| 55 | offset: 0, | |
| 56 | message: other.message(), | |
| 57 | }, | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 37 | 62 | impl fmt::Display for Error { |
| 38 | 63 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 39 | 64 | match self { |
| 40 | Self::PasswordMismatch => { | |
| 41 | f.write_str("The password did not match the section verifier") | |
| 42 | } | |
| 43 | Self::Unsupported => f.write_str("This protection format is not supported"), | |
| 44 | Self::Limit => f.write_str("Opening the protected section exceeded its work limit"), | |
| 45 | 65 | Self::Invalid(error) => error.fmt(f), |
| 66 | other => f.write_str(other.message()), | |
| 46 | 67 | } |
| 47 | 68 | } |
| 48 | 69 | } |
| ... | ... | @@ -79,17 +100,10 @@ impl Default for Limits { |
| 79 | 100 | } |
| 80 | 101 | } |
| 81 | 102 | |
| 82 | struct Decoded<'a> { | |
| 83 | stored: &'a [u8], | |
| 84 | clear: Zeroizing<Vec<u8>>, | |
| 85 | } | |
| 86 | ||
| 87 | /// Owns decoded buffers until dropped; returned views cannot outlive this owner. | |
| 88 | /// | |
| 89 | /// Passwords are not retained. Dropping this owner clears its derived key and | |
| 90 | /// decoded buffers; a protected save works on ordinary buffers, which are not cleared. Owned strings or exports made from a `Document` are separate | |
| 91 | /// caller-owned copies and must be disposed of by the caller when locking. | |
| 92 | /// Opening never rewrites the source image or changes its protection state. | |
| 103 | /// A protected section's labelled revisions decoded for reading as a `Document`, which | |
| 104 | /// cannot outlive it. Holds no password; dropping it clears its keys and decoded buffers, | |
| 105 | /// while strings and exports made from the `Document` are the caller's to dispose of. | |
| 106 | /// Edits go through `Section::unlock`. | |
| 93 | 107 | /// |
| 94 | 108 | /// ```compile_fail |
| 95 | 109 | /// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}}; |
| ... | ... | @@ -103,22 +117,30 @@ struct Decoded<'a> { |
| 103 | 117 | pub struct UnlockedSection<'a> { |
| 104 | 118 | index: &'a RevisionIndex<'a>, |
| 105 | 119 | /// By object space and stored address. |
| 106 | objects: BTreeMap<(ExGuid, usize), Decoded<'a>>, | |
| 120 | objects: BTreeMap<(ExGuid, usize), Zeroizing<Vec<u8>>>, | |
| 107 | 121 | files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>, |
| 108 | keys: BTreeMap<&'a [u8], crypto::Key>, | |
| 109 | 122 | } |
| 110 | 123 | |
| 111 | 124 | impl<'a> UnlockedSection<'a> { |
| 112 | 125 | /// Verifies the password and every labeled revision before exposing a view. |
| 113 | 126 | /// Metadata and password input are each bounded to 64 KiB. |
| 114 | 127 | pub fn open(index: &'a RevisionIndex<'a>, password: &str, limits: Limits) -> Result<Self> { |
| 115 | Self::unlock(index, limits, |metadata, rounds| { | |
| 128 | Self::open_with(index, limits, |metadata, rounds| { | |
| 116 | 129 | crypto::Key::open(metadata, password, rounds) |
| 117 | 130 | }) |
| 118 | 131 | } |
| 119 | 132 | |
| 120 | /// `open` with each distinct encryption metadata's key from `key`. | |
| 121 | fn unlock( | |
| 133 | /// `open` with the key `Key::open` gave, which every space's encryption data must name. | |
| 134 | pub fn unlock(index: &'a RevisionIndex<'a>, key: &Key, limits: Limits) -> Result<Self> { | |
| 135 | Self::open_with(index, limits, |metadata, _| { | |
| 136 | match metadata == &key.metadata[..] { | |
| 137 | true => Ok(key.key.clone()), | |
| 138 | false => Err(Error::PasswordMismatch), | |
| 139 | } | |
| 140 | }) | |
| 141 | } | |
| 142 | ||
| 143 | fn open_with( | |
| 122 | 144 | index: &'a RevisionIndex<'a>, |
| 123 | 145 | mut limits: Limits, |
| 124 | 146 | mut key: impl FnMut(&[u8], &mut u64) -> Result<crypto::Key>, |
| ... | ... | @@ -133,31 +155,10 @@ impl<'a> UnlockedSection<'a> { |
| 133 | 155 | index, |
| 134 | 156 | objects: BTreeMap::new(), |
| 135 | 157 | files: BTreeMap::new(), |
| 136 | keys: BTreeMap::new(), | |
| 137 | 158 | }; |
| 138 | 159 | let mut keys = BTreeMap::new(); |
| 139 | 160 | let mut file_keys = BTreeMap::new(); |
| 140 | let mut hashes = BTreeMap::new(); | |
| 141 | for node in index.store.lists.values().flat_map(|list| &list.nodes) { | |
| 142 | if !matches!(node.id, 0xc4 | 0xc5) { | |
| 143 | continue; | |
| 144 | } | |
| 145 | let Some(Reference::Data(chunk)) = node.reference else { | |
| 146 | return Err(invalid("Read-only object has no data reference")); | |
| 147 | }; | |
| 148 | let bytes = index.store.chunk_data(chunk)?; | |
| 149 | let expected: [u8; 16] = node | |
| 150 | .payload | |
| 151 | .last_chunk::<16>() | |
| 152 | .copied() | |
| 153 | .ok_or_else(|| invalid("Missing read-only object hash"))?; | |
| 154 | if hashes | |
| 155 | .insert(bytes.as_ptr().addr(), expected) | |
| 156 | .is_some_and(|old| old != expected) | |
| 157 | { | |
| 158 | return Err(invalid("Inconsistent read-only hashes for one payload")); | |
| 159 | } | |
| 160 | } | |
| 161 | let hashes = hashes(index.store)?; | |
| 161 | 162 | for (space_id, space) in &index.spaces { |
| 162 | 163 | let mut metadata = None; |
| 163 | 164 | for revision in space.revisions.values() { |
| ... | ... | @@ -203,23 +204,10 @@ impl<'a> UnlockedSection<'a> { |
| 203 | 204 | .checked_sub(bytes.len()) |
| 204 | 205 | .ok_or(Error::Limit)?; |
| 205 | 206 | let decoded = key.property(bytes)?; |
| 206 | if let Some(expected) = expected { | |
| 207 | let mut hash = md5::Context::new(); | |
| 208 | hash.consume(&decoded); | |
| 209 | hash.consume(&[0; 7][..(8 - decoded.len() % 8) % 8]); | |
| 210 | if hash.finalize().0 != *expected { | |
| 211 | return Err(invalid( | |
| 212 | "Decrypted read-only object hash mismatch", | |
| 213 | )); | |
| 214 | } | |
| 207 | if expected.is_some_and(|expected| digest(&decoded) != *expected) { | |
| 208 | return Err(invalid("Decrypted read-only object hash mismatch")); | |
| 215 | 209 | } |
| 216 | result.objects.insert( | |
| 217 | identity, | |
| 218 | Decoded { | |
| 219 | stored: bytes, | |
| 220 | clear: decoded, | |
| 221 | }, | |
| 222 | ); | |
| 210 | result.objects.insert(identity, decoded); | |
| 223 | 211 | } |
| 224 | 212 | ObjectData::File { .. } => { |
| 225 | 213 | if let Some(FileDataReference::Internal(guid)) = |
| ... | ... | @@ -251,7 +239,6 @@ impl<'a> UnlockedSection<'a> { |
| 251 | 239 | } |
| 252 | 240 | } |
| 253 | 241 | } |
| 254 | result.keys = keys; | |
| 255 | 242 | for (space, info) in &index.spaces { |
| 256 | 243 | for rid in info.labels.values().copied().collect::<BTreeSet<_>>() { |
| 257 | 244 | result.resolve(*space, rid)?.reachable()?; |
| ... | ... | @@ -276,7 +263,7 @@ impl<'a> UnlockedSection<'a> { |
| 276 | 263 | offset: 0, |
| 277 | 264 | message: "Protected object was not decoded", |
| 278 | 265 | })?; |
| 279 | object.data = ObjectData::Properties(&decoded.clear); | |
| 266 | object.data = ObjectData::Properties(decoded); | |
| 280 | 267 | } |
| 281 | 268 | } |
| 282 | 269 | Ok(revision) |
| ... | ... | @@ -299,216 +286,345 @@ impl<'a> UnlockedSection<'a> { |
| 299 | 286 | } |
| 300 | 287 | } |
| 301 | 288 | |
| 302 | impl UnlockedSection<'_> { | |
| 303 | /// A plaintext section holding every object space's current revision and payloads | |
| 304 | /// under their own identities, for writers that read ordinary sections. | |
| 305 | fn twin(&self) -> std::result::Result<Zeroizing<Vec<u8>>, crate::Error> { | |
| 306 | use crate::write::{PropertyObject, RevisionEdit, append_revisions}; | |
| 307 | let copy = |space: ExGuid, revision: ExGuid| { | |
| 308 | let revision = self.resolve(space, revision)?; | |
| 309 | // A revision still declares objects its edits detached; a new one holds live ones. | |
| 310 | let live = revision.reachable()?; | |
| 311 | let mut objects = BTreeMap::new(); | |
| 312 | for (id, object) in revision.objects.iter().filter(|(id, _)| live.contains(id)) { | |
| 313 | let copy = match object.data { | |
| 314 | ObjectData::File { | |
| 315 | reference, | |
| 316 | extension, | |
| 317 | } => { | |
| 318 | let text = |bytes: &[u8]| { | |
| 319 | String::from_utf16( | |
| 320 | &bytes | |
| 321 | .chunks_exact(2) | |
| 322 | .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) | |
| 323 | .collect::<Vec<_>>(), | |
| 324 | ) | |
| 325 | .map_err(|_| crate::Error { | |
| 326 | offset: 0, | |
| 327 | message: "Invalid UTF-16 file-data declaration", | |
| 328 | }) | |
| 329 | }; | |
| 330 | let mut copy = | |
| 331 | PropertyObject::file(*id, &text(reference)?, &text(extension)?)?; | |
| 332 | copy.jcid = object.jcid; | |
| 333 | copy.global_ids = std::sync::Arc::clone(&object.global_ids); | |
| 334 | copy | |
| 335 | } | |
| 336 | _ => PropertyObject::from_object(object)?, | |
| 337 | }; | |
| 338 | objects.insert(*id, copy); | |
| 339 | } | |
| 340 | Ok::<_, crate::Error>((revision.roots, objects)) | |
| 341 | }; | |
| 342 | let payloads: Vec<_> = self | |
| 343 | .files | |
| 344 | .iter() | |
| 345 | .map(|(id, bytes)| (*id, bytes.as_slice())) | |
| 346 | .collect(); | |
| 347 | let current = (ExGuid::default(), 1); | |
| 348 | // The scaffold's root space takes the section's identity, then its content. | |
| 349 | let mut scaffold = crate::create_section("twin.one", "", "")?; | |
| 350 | let identity = |id: ExGuid| { | |
| 351 | let mut bytes = Vec::new(); | |
| 352 | id.encode(&mut bytes); | |
| 353 | bytes | |
| 354 | }; | |
| 355 | let store = crate::Store::parse(&scaffold)?; | |
| 356 | let placeholder = identity(RevisionIndex::parse(&store)?.root); | |
| 357 | for at in 0..=scaffold.len() - 20 { | |
| 358 | if scaffold[at..at + 20] == placeholder { | |
| 359 | scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); | |
| 289 | /// A protected section's key, opened with its password or made for a new one. Holds no | |
| 290 | /// password; the key is cleared when its last clone drops. | |
| 291 | #[derive(Clone)] | |
| 292 | pub struct Key { | |
| 293 | key: crypto::Key, | |
| 294 | /// The encryption data (MS-ONESTORE 2.5.19) that opens the key. | |
| 295 | metadata: Arc<[u8]>, | |
| 296 | } | |
| 297 | ||
| 298 | impl fmt::Debug for Key { | |
| 299 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 300 | f.write_str("Key") | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | impl Key { | |
| 305 | /// Opens the key of the protected section `image` with `password`. | |
| 306 | pub fn open(image: &[u8], password: &str) -> Result<Self> { | |
| 307 | let store = Store::parse(image)?; | |
| 308 | let metadata = metadata(&store)?.ok_or(Error::Unsupported)?; | |
| 309 | Ok(Self { | |
| 310 | key: crypto::Key::open(metadata, password, &mut Limits::default().kdf_rounds)?, | |
| 311 | metadata: Arc::from(metadata), | |
| 312 | }) | |
| 313 | } | |
| 314 | ||
| 315 | /// A fresh key for `password`, with OneNote 2010's encryption data. | |
| 316 | pub fn new(password: &str) -> Result<Self> { | |
| 317 | let (key, metadata) = crypto::Key::create(password)?; | |
| 318 | Ok(Self { | |
| 319 | key, | |
| 320 | metadata: Arc::from(metadata), | |
| 321 | }) | |
| 322 | } | |
| 323 | ||
| 324 | /// The AES key the section's content is encrypted under, for keys derived from it. | |
| 325 | pub fn secret(&self) -> &[u8; 16] { | |
| 326 | self.key.value() | |
| 327 | } | |
| 328 | ||
| 329 | pub(crate) fn metadata(&self) -> &[u8] { | |
| 330 | &self.metadata | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | /// The section `image` written anew, under `new` or in the clear, as OneNote 2010 rewrites | |
| 335 | /// a section whose password it sets, changes or removes: the file and its object spaces | |
| 336 | /// take new identities, each space keeps its labelled revisions, every one a checkpoint, | |
| 337 | /// and the payloads come along. A protected `image` opens under `key`. | |
| 338 | pub fn rekey(image: &[u8], key: Option<&Key>, new: Option<&Key>) -> Result<Vec<u8>> { | |
| 339 | let store = Store::parse(image)?; | |
| 340 | if !store.checksum_mismatches.is_empty() { | |
| 341 | return Err(invalid( | |
| 342 | "Cannot write a file with transaction checksum damage", | |
| 343 | )); | |
| 344 | } | |
| 345 | let index = RevisionIndex::parse(&store)?; | |
| 346 | let opened = match (key, metadata(&store)?) { | |
| 347 | (Some(key), Some(_)) => Some(Opened::new(&store, key)?), | |
| 348 | (None, None) => None, | |
| 349 | (None, Some(_)) => return Err(Error::PasswordMismatch), | |
| 350 | (Some(_), None) => return Err(invalid("The section is not protected")), | |
| 351 | }; | |
| 352 | let arena = Bump::new(); | |
| 353 | let mut fresh = BTreeMap::new(); | |
| 354 | for space in index.spaces.keys() { | |
| 355 | if let std::collections::btree_map::Entry::Vacant(entry) = fresh.entry(space.guid) { | |
| 356 | entry.insert(crate::write::fresh_guid()?); | |
| 357 | } | |
| 358 | } | |
| 359 | // OneNote gives the payloads new identities too. | |
| 360 | let mut files = BTreeMap::new(); | |
| 361 | for guid in crate::write::declared_payloads(&store) { | |
| 362 | files.insert(guid, crate::write::fresh_guid()?); | |
| 363 | } | |
| 364 | let rename = |id: ExGuid| ExGuid { | |
| 365 | guid: fresh.get(&id.guid).copied().unwrap_or(id.guid), | |
| 366 | n: id.n, | |
| 367 | }; | |
| 368 | let mut spaces = Vec::new(); | |
| 369 | for (space, info) in &index.spaces { | |
| 370 | let mut labelled: Vec<(ExGuid, Vec<(ExGuid, u32)>)> = Vec::new(); | |
| 371 | // The current revision first, as OneNote writes it. | |
| 372 | let mut labels: Vec<_> = info.labels.iter().collect(); | |
| 373 | labels.sort_by_key(|(label, _)| **label != (ExGuid::default(), 1)); | |
| 374 | for (label, rid) in labels { | |
| 375 | match labelled.iter_mut().find(|(known, _)| known == rid) { | |
| 376 | Some((_, names)) => names.push(*label), | |
| 377 | None => labelled.push((*rid, vec![*label])), | |
| 360 | 378 | } |
| 361 | 379 | } |
| 362 | let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| { | |
| 363 | let mut spaces = BTreeMap::new(); | |
| 364 | for id in self.index.spaces.keys() { | |
| 365 | let (roots, objects) = copy(*id, self.index.active(*id)?)?; | |
| 366 | let edit = if *id == self.index.root { | |
| 367 | RevisionEdit::Label { | |
| 368 | context: current.0, | |
| 369 | role: current.1, | |
| 370 | roots, | |
| 371 | objects, | |
| 380 | let mut revisions = Vec::new(); | |
| 381 | for (rid, labels) in labelled { | |
| 382 | let mut resolved = index.resolve(*space, rid)?; | |
| 383 | if let Some(opened) = &opened { | |
| 384 | for object in resolved.objects.values_mut() { | |
| 385 | if let ObjectData::Encrypted(stored) = object.data { | |
| 386 | object.data = ObjectData::Properties(opened.property(&arena, stored)?); | |
| 372 | 387 | } |
| 373 | } else { | |
| 374 | RevisionEdit::Create { roots, objects } | |
| 375 | }; | |
| 376 | spaces.insert(*id, edit); | |
| 377 | } | |
| 378 | Ok(spaces) | |
| 379 | })?); | |
| 380 | // One revision per call and space: the remaining labels follow in rounds. | |
| 381 | for round in 0.. { | |
| 382 | let mut spaces = BTreeMap::new(); | |
| 383 | for (id, space) in &self.index.spaces { | |
| 384 | let label = space.labels.iter().filter(|(label, _)| **label != current); | |
| 385 | if let Some(((context, role), revision)) = label.clone().nth(round) { | |
| 386 | let (roots, objects) = copy(*id, *revision)?; | |
| 387 | spaces.insert( | |
| 388 | *id, | |
| 389 | RevisionEdit::Label { | |
| 390 | context: *context, | |
| 391 | role: *role, | |
| 392 | roots, | |
| 393 | objects, | |
| 394 | }, | |
| 395 | ); | |
| 396 | 388 | } |
| 397 | 389 | } |
| 398 | if spaces.is_empty() { | |
| 399 | break; | |
| 390 | let mut objects = BTreeMap::new(); | |
| 391 | for id in resolved.reachable()? { | |
| 392 | let mut object = resolved.objects[&id].clone(); | |
| 393 | if let (Some(FileDataReference::Internal(guid)), ObjectData::File { extension, .. }) = | |
| 394 | (object.file_reference()?, object.data) | |
| 395 | && let Some(renamed) = files.get(&guid) | |
| 396 | { | |
| 397 | let reference: Vec<u8> = crate::op::content::payload_reference(*renamed) | |
| 398 | .encode_utf16() | |
| 399 | .flat_map(u16::to_le_bytes) | |
| 400 | .collect(); | |
| 401 | object.data = ObjectData::File { | |
| 402 | reference: arena.alloc_slice_copy(&reference), | |
| 403 | extension, | |
| 404 | }; | |
| 405 | } | |
| 406 | object.global_ids = Arc::new( | |
| 407 | object | |
| 408 | .global_ids | |
| 409 | .iter() | |
| 410 | .map(|(entry, guid)| (*entry, fresh.get(guid).copied().unwrap_or(*guid))) | |
| 411 | .collect(), | |
| 412 | ); | |
| 413 | objects.insert(rename(id), object); | |
| 400 | 414 | } |
| 401 | twin = Zeroizing::new(append_revisions(&twin, &[], None, |_| Ok(spaces))?); | |
| 415 | let roots = resolved | |
| 416 | .roots | |
| 417 | .iter() | |
| 418 | .map(|(role, id)| (*role, rename(*id))) | |
| 419 | .collect(); | |
| 420 | revisions.push(crate::write::Labelled { | |
| 421 | labels, | |
| 422 | revision: crate::ResolvedRevision { roots, objects }, | |
| 423 | }); | |
| 402 | 424 | } |
| 403 | Ok(twin) | |
| 425 | spaces.push((rename(*space), revisions)); | |
| 426 | } | |
| 427 | let mut payloads = Vec::new(); | |
| 428 | for (guid, renamed) in &files { | |
| 429 | let stored = store.file_data(*guid)?; | |
| 430 | let clear = match &opened { | |
| 431 | Some(opened) => opened.file(&arena, stored)?, | |
| 432 | None => stored, | |
| 433 | }; | |
| 434 | payloads.push((*renamed, clear)); | |
| 404 | 435 | } |
| 436 | let placement = image[128..148].try_into().unwrap(); | |
| 437 | let skeleton = crate::create::skeleton(rename(index.root), placement)?; | |
| 438 | let written = crate::write::rewrite(skeleton, &spaces, &payloads, new)?; | |
| 439 | let arena = crate::Arena::default(); | |
| 440 | match new { | |
| 441 | Some(new) => drop(crate::Section::unlock(&arena, written.clone(), new)?), | |
| 442 | None => drop(crate::Section::open(&arena, written.clone())?), | |
| 443 | } | |
| 444 | Ok(written) | |
| 405 | 445 | } |
| 406 | 446 | |
| 407 | impl UnlockedSection<'_> { | |
| 408 | /// Applies `edit` to this section as `Section::apply` applies it to an ordinary one and | |
| 409 | /// seals the result as one revision per changed space, stored under the section's key. | |
| 410 | /// Page ops only: a protected section's page list is not edited. | |
| 411 | pub fn apply(&self, author: &str, edit: &Edit) -> std::result::Result<Transaction, OpError> { | |
| 412 | let failed = |error: crate::Error| OpError::Failed(error); | |
| 413 | if self.keys.len() != 1 { | |
| 414 | return Err(OpError::Unsupported( | |
| 415 | "Only a section under one key takes edits", | |
| 416 | )); | |
| 447 | /// The encryption data every revision of `store` names; none in a section without any. | |
| 448 | fn metadata<'a>(store: &Store<'a>) -> Result<Option<&'a [u8]>> { | |
| 449 | let mut found = None; | |
| 450 | for node in store.lists.values().flat_map(|list| &list.nodes) { | |
| 451 | if node.id != 0x7c { | |
| 452 | continue; | |
| 417 | 453 | } |
| 418 | if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) { | |
| 419 | return Err(OpError::Unsupported( | |
| 420 | "A protected section's page list is not edited", | |
| 421 | )); | |
| 454 | let Some(Reference::Data(chunk)) = node.reference else { | |
| 455 | return Err(invalid("Missing encryption key reference")); | |
| 456 | }; | |
| 457 | let data = store.encryption_key(chunk)?; | |
| 458 | if found.replace(data).is_some_and(|old| old != data) { | |
| 459 | return Err(Error::Unsupported); | |
| 460 | } | |
| 461 | } | |
| 462 | Ok(found) | |
| 463 | } | |
| 464 | ||
| 465 | /// The hashes read-only declarations give their stored bytes, by address. | |
| 466 | fn hashes(store: &Store<'_>) -> Result<BTreeMap<usize, [u8; 16]>> { | |
| 467 | let mut hashes = BTreeMap::new(); | |
| 468 | for node in store.lists.values().flat_map(|list| &list.nodes) { | |
| 469 | if !matches!(node.id, 0xc4 | 0xc5) { | |
| 470 | continue; | |
| 471 | } | |
| 472 | let Some(Reference::Data(chunk)) = node.reference else { | |
| 473 | return Err(invalid("Read-only object has no data reference")); | |
| 474 | }; | |
| 475 | let bytes = store.chunk_data(chunk)?; | |
| 476 | let expected: [u8; 16] = node | |
| 477 | .payload | |
| 478 | .last_chunk::<16>() | |
| 479 | .copied() | |
| 480 | .ok_or_else(|| invalid("Missing read-only object hash"))?; | |
| 481 | if hashes | |
| 482 | .insert(bytes.as_ptr().addr(), expected) | |
| 483 | .is_some_and(|old| old != expected) | |
| 484 | { | |
| 485 | return Err(invalid("Inconsistent read-only hashes for one payload")); | |
| 486 | } | |
| 487 | } | |
| 488 | Ok(hashes) | |
| 489 | } | |
| 490 | ||
| 491 | /// A protected read-only declaration's hash: MD5 of the clear bytes zero-padded to 8. | |
| 492 | pub(crate) fn digest(clear: &[u8]) -> [u8; 16] { | |
| 493 | let mut hash = md5::Context::new(); | |
| 494 | hash.consume(clear); | |
| 495 | hash.consume(&[0; 7][..(8 - clear.len() % 8) % 8]); | |
| 496 | hash.finalize().0 | |
| 497 | } | |
| 498 | ||
| 499 | /// A `Section`'s key, with what it decoded under it: clear bytes by the address of the | |
| 500 | /// stored bytes they decode, and stored bytes by the address of their clear bytes. | |
| 501 | pub(crate) struct Opened<'a> { | |
| 502 | key: Key, | |
| 503 | /// The encryption data each revision names. | |
| 504 | chunk: Chunk, | |
| 505 | /// Read-only declarations' hashes by the address of their stored bytes. | |
| 506 | hashes: BTreeMap<usize, [u8; 16]>, | |
| 507 | clear: RefCell<BTreeMap<usize, &'a [u8]>>, | |
| 508 | stored: RefCell<BTreeMap<usize, &'a [u8]>>, | |
| 509 | } | |
| 510 | ||
| 511 | impl<'a> Opened<'a> { | |
| 512 | /// `key` for the section `store` holds, every object space of which it must encrypt. | |
| 513 | pub(crate) fn new(store: &Store<'_>, key: &Key) -> Result<Self> { | |
| 514 | if metadata(store)?.ok_or(Error::Unsupported)? != &key.metadata[..] { | |
| 515 | return Err(Error::PasswordMismatch); | |
| 422 | 516 | } |
| 423 | let twin = self.twin().map_err(failed)?; | |
| 424 | let arena = crate::Arena::default(); | |
| 425 | let mut section = crate::Section::open(&arena, twin.to_vec()).map_err(failed)?; | |
| 426 | section.apply(author, edit)?; | |
| 427 | section.seal().map_err(failed)?; | |
| 428 | let applied = Zeroizing::new(section.image()); | |
| 429 | drop(section); | |
| 430 | let applied_store = crate::Store::parse(&applied).map_err(failed)?; | |
| 431 | let applied_index = RevisionIndex::parse(&applied_store).map_err(failed)?; | |
| 432 | // The twin's scaffold spaces are not the section's. | |
| 433 | let edited = self | |
| 434 | .index | |
| 517 | let index = RevisionIndex::parse(store)?; | |
| 518 | if index | |
| 435 | 519 | .spaces |
| 436 | .keys() | |
| 437 | .map(|space| Ok((*space, applied_index.resolve_active(*space)?))) | |
| 438 | .collect::<std::result::Result<Vec<_>, crate::Error>>() | |
| 439 | .map_err(failed)?; | |
| 440 | let edited: Vec<_> = edited | |
| 441 | .iter() | |
| 442 | .map(|(space, after)| (*space, after)) | |
| 443 | .collect(); | |
| 444 | let payloads = crate::write::declared_payloads(&applied_store) | |
| 445 | .into_iter() | |
| 446 | .map(|guid| Ok((guid, applied_store.file_data(guid)?))) | |
| 447 | .collect::<std::result::Result<Vec<_>, crate::Error>>() | |
| 448 | .map_err(failed)?; | |
| 449 | let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self)) | |
| 450 | .map_err(failed)? | |
| 451 | .ok_or(OpError::Unsupported("The edit changes nothing"))?; | |
| 452 | let written = | |
| 453 | crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?; | |
| 454 | let store = crate::Store::parse(&written).map_err(failed)?; | |
| 455 | let index = RevisionIndex::parse(&store).map_err(failed)?; | |
| 456 | UnlockedSection::unlock(&index, Limits::default(), |metadata, _| { | |
| 457 | self.keys | |
| 458 | .get(metadata) | |
| 459 | .cloned() | |
| 460 | .ok_or(Error::PasswordMismatch) | |
| 520 | .values() | |
| 521 | .flat_map(|space| space.revisions.values()) | |
| 522 | .any(|revision| !revision.encrypted) | |
| 523 | { | |
| 524 | return Err(invalid( | |
| 525 | "A protected section stores a revision in the clear", | |
| 526 | )); | |
| 527 | } | |
| 528 | let chunk = store | |
| 529 | .lists | |
| 530 | .values() | |
| 531 | .flat_map(|list| &list.nodes) | |
| 532 | .find_map(|node| match node.reference { | |
| 533 | Some(Reference::Data(chunk)) if node.id == 0x7c => Some(chunk), | |
| 534 | _ => None, | |
| 535 | }) | |
| 536 | .ok_or(Error::Unsupported)?; | |
| 537 | Ok(Self { | |
| 538 | key: key.clone(), | |
| 539 | chunk, | |
| 540 | hashes: hashes(store)?, | |
| 541 | clear: RefCell::default(), | |
| 542 | stored: RefCell::default(), | |
| 461 | 543 | }) |
| 462 | .map_err(|error| match error { | |
| 463 | Error::Invalid(error) => failed(error), | |
| 464 | _ => failed(crate::Error { | |
| 544 | } | |
| 545 | ||
| 546 | /// `key` for writing a new image whose encryption data lies at `chunk`. | |
| 547 | pub(crate) fn sealing(key: &Key, chunk: Chunk) -> Self { | |
| 548 | Self { | |
| 549 | key: key.clone(), | |
| 550 | chunk, | |
| 551 | hashes: BTreeMap::new(), | |
| 552 | clear: RefCell::default(), | |
| 553 | stored: RefCell::default(), | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | /// The clear bytes of a stored object, decoded once into `arena`. | |
| 558 | pub(crate) fn property(&self, arena: &'a Bump, stored: &'a [u8]) -> Format<&'a [u8]> { | |
| 559 | if let Some(clear) = self.clear.borrow().get(&stored.as_ptr().addr()) { | |
| 560 | return Ok(clear); | |
| 561 | } | |
| 562 | let decoded = self.key.key.property(stored).map_err(crate::Error::from)?; | |
| 563 | if let Some(expected) = self.hashes.get(&stored.as_ptr().addr()) | |
| 564 | && digest(&decoded) != *expected | |
| 565 | { | |
| 566 | return Err(crate::Error { | |
| 465 | 567 | offset: 0, |
| 466 | message: "The written section does not unlock under its key", | |
| 467 | }), | |
| 468 | })?; | |
| 469 | Ok(transaction) | |
| 568 | message: "Decrypted read-only object hash mismatch", | |
| 569 | }); | |
| 570 | } | |
| 571 | let clear: &'a [u8] = arena.alloc_slice_copy(&decoded); | |
| 572 | self.sealed(clear, stored); | |
| 573 | Ok(clear) | |
| 470 | 574 | } |
| 471 | } | |
| 472 | 575 | |
| 473 | impl UnlockedSection<'_> { | |
| 474 | /// The section key; `apply` admits sections with exactly one. | |
| 475 | fn key(&self) -> &crypto::Key { | |
| 476 | self.keys.values().next().expect("one section key") | |
| 576 | /// The clear bytes of a stored payload, in `arena`. | |
| 577 | pub(crate) fn file(&self, arena: &'a Bump, stored: &[u8]) -> Format<&'a [u8]> { | |
| 578 | Ok(arena.alloc_slice_copy(&self.key.key.file(stored).map_err(crate::Error::from)?)) | |
| 477 | 579 | } |
| 478 | } | |
| 479 | 580 | |
| 480 | impl crate::write::Protection for UnlockedSection<'_> { | |
| 481 | fn resolve( | |
| 482 | &self, | |
| 483 | space: ExGuid, | |
| 484 | revision: ExGuid, | |
| 485 | ) -> std::result::Result<crate::ResolvedRevision<'_>, crate::Error> { | |
| 486 | self.resolve(space, revision) | |
| 581 | pub(crate) fn key(&self) -> &Key { | |
| 582 | &self.key | |
| 487 | 583 | } |
| 488 | 584 | |
| 585 | /// Records that `clear` is stored as `stored`. | |
| 586 | pub(crate) fn sealed(&self, clear: &'a [u8], stored: &'a [u8]) { | |
| 587 | self.clear | |
| 588 | .borrow_mut() | |
| 589 | .insert(stored.as_ptr().addr(), clear); | |
| 590 | self.stored | |
| 591 | .borrow_mut() | |
| 592 | .insert(clear.as_ptr().addr(), stored); | |
| 593 | } | |
| 594 | } | |
| 595 | ||
| 596 | impl crate::write::Protection for Opened<'_> { | |
| 489 | 597 | fn stored(&self, clear: &[u8]) -> Option<&[u8]> { |
| 490 | self.objects | |
| 491 | .values() | |
| 492 | .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr())) | |
| 493 | .map(|decoded| decoded.stored) | |
| 598 | self.stored.borrow().get(&clear.as_ptr().addr()).copied() | |
| 494 | 599 | } |
| 495 | 600 | |
| 496 | fn seal_property(&self, clear: &[u8]) -> std::result::Result<Vec<u8>, crate::Error> { | |
| 497 | let random = crate::Error { | |
| 498 | offset: 0, | |
| 499 | message: "System random source failed", | |
| 500 | }; | |
| 501 | let mut iv = [0; 16]; | |
| 502 | getrandom::fill(&mut iv).map_err(|_| random)?; | |
| 503 | self.key() | |
| 504 | .seal_property(clear, iv) | |
| 505 | .map_err(|error| match error { | |
| 506 | Error::Invalid(error) => error, | |
| 507 | _ => random, | |
| 508 | }) | |
| 601 | fn seal_property(&self, clear: &[u8]) -> Format<Vec<u8>> { | |
| 602 | seal_property(&self.key, clear) | |
| 603 | } | |
| 604 | ||
| 605 | fn seal_file(&self, clear: &[u8]) -> Format<Vec<u8>> { | |
| 606 | self.key.key.seal_file(clear).map_err(crate::Error::from) | |
| 607 | } | |
| 608 | ||
| 609 | fn open_property(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> { | |
| 610 | self.key.key.property(stored).map_err(crate::Error::from) | |
| 509 | 611 | } |
| 510 | 612 | |
| 511 | fn seal_file(&self, clear: &[u8]) -> Vec<u8> { | |
| 512 | self.key().seal_file(clear) | |
| 613 | fn open_file(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> { | |
| 614 | self.key.key.file(stored).map_err(crate::Error::from) | |
| 513 | 615 | } |
| 616 | ||
| 617 | fn metadata(&self) -> Chunk { | |
| 618 | self.chunk | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 622 | /// A property object's stored form under `key`, with a fresh IV. | |
| 623 | fn seal_property(key: &Key, clear: &[u8]) -> Format<Vec<u8>> { | |
| 624 | let mut iv = [0; 16]; | |
| 625 | getrandom::fill(&mut iv).map_err(|_| crate::Error { | |
| 626 | offset: 0, | |
| 627 | message: "System random source failed", | |
| 628 | })?; | |
| 629 | key.key.seal_property(clear, iv).map_err(crate::Error::from) | |
| 514 | 630 | } |
crates/onestore/src/section.rs+133-77| ... | ... | @@ -7,9 +7,10 @@ use crate::{ |
| 7 | 7 | active::{ActivePage, Changes, Files}, |
| 8 | 8 | document::Revision, |
| 9 | 9 | page::Page, |
| 10 | protected::{Key, Opened}, | |
| 10 | 11 | store::StoreState, |
| 11 | 12 | write::{ |
| 12 | Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth, | |
| 13 | Appending, Commit, LiveRevision, PropertyObject, Protection, Sealing, Written, chain_depth, | |
| 13 | 14 | check_transaction, declared, label_payload, node, replacement, unchanged, |
| 14 | 15 | }, |
| 15 | 16 | }; |
| ... | ... | @@ -34,8 +35,8 @@ pub struct Section<'a> { |
| 34 | 35 | /// The in-place writes of the sealed transactions, in order. |
| 35 | 36 | patches: Vec<(u64, Vec<u8>)>, |
| 36 | 37 | state: StoreState, |
| 37 | /// Payloads the opened images embed, by identity, with the image holding each. | |
| 38 | files: &'a [([u8; 16], Chunk, &'a [u8])], | |
| 38 | /// Payloads the opened images embed, by identity, as they read. | |
| 39 | files: &'a [([u8; 16], Result<&'a [u8]>)], | |
| 39 | 40 | /// Payload identities the file-data store declares. |
| 40 | 41 | declared: BTreeSet<[u8; 16]>, |
| 41 | 42 | spaces: BTreeMap<ExGuid, Space<'a>>, |
| ... | ... | @@ -48,6 +49,8 @@ pub struct Section<'a> { |
| 48 | 49 | root: ExGuid, |
| 49 | 50 | /// While an edit applies, the spaces it changed as they were before it. |
| 50 | 51 | undo: Option<Undo<'a>>, |
| 52 | /// A password-protected section's key and what it decoded. | |
| 53 | unlocked: Option<Opened<'a>>, | |
| 51 | 54 | } |
| 52 | 55 | |
| 53 | 56 | #[derive(Default)] |
| ... | ... | @@ -139,9 +142,17 @@ struct Open<'a> { |
| 139 | 142 | impl<'a> Section<'a> { |
| 140 | 143 | /// Parses and fully validates a section image. |
| 141 | 144 | pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> { |
| 142 | Self::open_with(arena, vec![image], |_, space| { | |
| 143 | space.labels.get(&(ExGuid::default(), 1)).copied() | |
| 144 | }) | |
| 145 | Ok(Self::open_with(arena, vec![image], None, current)?) | |
| 146 | } | |
| 147 | ||
| 148 | /// `open` for a password-protected section, decoding it under `key`, which edits are | |
| 149 | /// sealed under too. Decoded bytes live in `arena` until it drops. | |
| 150 | pub fn unlock( | |
| 151 | arena: &'a Arena, | |
| 152 | image: Vec<u8>, | |
| 153 | key: &Key, | |
| 154 | ) -> std::result::Result<Self, crate::protected::Error> { | |
| 155 | Self::open_with(arena, vec![image], Some(key), current) | |
| 145 | 156 | } |
| 146 | 157 | |
| 147 | 158 | /// Opens an earlier state of a section: each object space in `revisions` at the revision |
| ... | ... | @@ -153,7 +164,7 @@ impl<'a> Section<'a> { |
| 153 | 164 | images: Vec<Vec<u8>>, |
| 154 | 165 | revisions: &BTreeMap<ExGuid, ExGuid>, |
| 155 | 166 | ) -> Result<Self> { |
| 156 | let section = Self::open_with(arena, images, |id, space| { | |
| 167 | let section = Self::open_with(arena, images, None, |id, space| { | |
| 157 | 168 | revisions |
| 158 | 169 | .get(id) |
| 159 | 170 | .copied() |
| ... | ... | @@ -170,15 +181,17 @@ impl<'a> Section<'a> { |
| 170 | 181 | |
| 171 | 182 | /// `open`, each object space at the revision `revision` chooses in the first image where it |
| 172 | 183 | /// chooses one; a space it chooses none for is left out. Payloads are read from the image |
| 173 | /// that first declares them. | |
| 184 | /// that first declares them. A protected section opens under `key`. | |
| 174 | 185 | fn open_with( |
| 175 | 186 | arena: &'a Arena, |
| 176 | 187 | images: Vec<Vec<u8>>, |
| 188 | key: Option<&Key>, | |
| 177 | 189 | revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option<ExGuid>, |
| 178 | ) -> Result<Self> { | |
| 190 | ) -> std::result::Result<Self, crate::protected::Error> { | |
| 179 | 191 | let mut opened = None; |
| 192 | let mut unlocked = None; | |
| 180 | 193 | let mut spaces = BTreeMap::new(); |
| 181 | let mut files: Vec<([u8; 16], Chunk, &'a [u8])> = Vec::new(); | |
| 194 | let mut files: Vec<([u8; 16], Result<&'a [u8]>)> = Vec::new(); | |
| 182 | 195 | for image in images { |
| 183 | 196 | let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); |
| 184 | 197 | drop(image); |
| ... | ... | @@ -187,28 +200,40 @@ impl<'a> Section<'a> { |
| 187 | 200 | return Err(Error { |
| 188 | 201 | offset: *offset, |
| 189 | 202 | message: "Cannot write a file with transaction checksum damage", |
| 190 | }); | |
| 203 | } | |
| 204 | .into()); | |
| 191 | 205 | } |
| 192 | 206 | if store.header.file_type != FileType::Section { |
| 193 | 207 | return Err(Error { |
| 194 | 208 | offset: 0, |
| 195 | 209 | message: "Choose a section file", |
| 196 | }); | |
| 210 | } | |
| 211 | .into()); | |
| 197 | 212 | } |
| 198 | 213 | let index = RevisionIndex::parse(&store)?; |
| 199 | index.validate_current()?; | |
| 214 | if let (Some(key), None) = (key, &unlocked) { | |
| 215 | unlocked = Some(Opened::new(&store, key)?); | |
| 216 | } | |
| 217 | // The parse borrows `bytes` only as long as its store; its slices lie in `bytes`, | |
| 218 | // which the arena keeps. | |
| 219 | let bound = |resolved| { | |
| 220 | bind(arena, unlocked.as_ref(), resolved, |part| { | |
| 221 | Ok(within(bytes, part)) | |
| 222 | }) | |
| 223 | }; | |
| 224 | index.validate_with(|space, rid| bound(index.resolve(space, rid)?))?; | |
| 200 | 225 | for (id, space) in &index.spaces { |
| 201 | 226 | let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else { |
| 202 | 227 | continue; |
| 203 | 228 | }; |
| 204 | let revision = bound(bytes, index.resolve(*id, rid)?); | |
| 229 | let revision = bound(index.resolve(*id, rid)?)?; | |
| 205 | 230 | let history = space |
| 206 | 231 | .labels |
| 207 | 232 | .get(&(versions::HISTORY, 1)) |
| 208 | 233 | .map(|history| -> Result<History<'a>> { |
| 209 | 234 | Ok(History { |
| 210 | 235 | rid: *history, |
| 211 | revision: bound(bytes, index.resolve(*id, *history)?), | |
| 236 | revision: bound(index.resolve(*id, *history)?)?, | |
| 212 | 237 | depth: chain_depth(&index, *id, *history), |
| 213 | 238 | pending: BTreeMap::new(), |
| 214 | 239 | }) |
| ... | ... | @@ -231,11 +256,24 @@ impl<'a> Section<'a> { |
| 231 | 256 | return Err(Error { |
| 232 | 257 | offset: node.offset, |
| 233 | 258 | message: "File-data object lacks a data reference", |
| 234 | }); | |
| 259 | } | |
| 260 | .into()); | |
| 235 | 261 | }; |
| 236 | 262 | // A payload an earlier image declares is the same bytes: it names them. |
| 237 | 263 | if !files[..known].iter().any(|(declared, ..)| declared == guid) { |
| 238 | files.push((*guid, chunk, bytes)); | |
| 264 | let (offset, length) = (chunk.offset as usize, chunk.length as usize); | |
| 265 | let payload = bytes | |
| 266 | .get(offset..offset + length) | |
| 267 | .ok_or(Error { | |
| 268 | offset, | |
| 269 | message: "Chunk extends outside the data area", | |
| 270 | }) | |
| 271 | .and_then(|blob| crate::files::payload(blob, offset)) | |
| 272 | .and_then(|payload| match &unlocked { | |
| 273 | Some(unlocked) => unlocked.file(&arena.0, payload), | |
| 274 | None => Ok(payload), | |
| 275 | }); | |
| 276 | files.push((*guid, payload)); | |
| 239 | 277 | } |
| 240 | 278 | } |
| 241 | 279 | if opened.is_none() { |
| ... | ... | @@ -259,6 +297,7 @@ impl<'a> Section<'a> { |
| 259 | 297 | broken: false, |
| 260 | 298 | root, |
| 261 | 299 | undo: None, |
| 300 | unlocked, | |
| 262 | 301 | }) |
| 263 | 302 | } |
| 264 | 303 | |
| ... | ... | @@ -479,25 +518,14 @@ impl<'a> Section<'a> { |
| 479 | 518 | fn files(&self) -> Files<'a> { |
| 480 | 519 | let files = self.files; |
| 481 | 520 | std::rc::Rc::new(move |guid| { |
| 482 | let start = files.partition_point(|(id, ..)| *id < guid); | |
| 483 | match files[start..] | |
| 484 | .iter() | |
| 485 | .take_while(|(id, ..)| *id == guid) | |
| 486 | .count() | |
| 487 | { | |
| 488 | 0 => Err(Error { | |
| 521 | let start = files.partition_point(|(id, _)| *id < guid); | |
| 522 | let mut declared = files[start..].iter().take_while(|(id, _)| *id == guid); | |
| 523 | match (declared.next(), declared.next()) { | |
| 524 | (None, _) => Err(Error { | |
| 489 | 525 | offset: 0, |
| 490 | 526 | message: "File-data object is not declared", |
| 491 | 527 | }), |
| 492 | 1 => { | |
| 493 | let (_, chunk, bytes) = files[start]; | |
| 494 | let (offset, length) = (chunk.offset as usize, chunk.length as usize); | |
| 495 | let blob = bytes.get(offset..offset + length).ok_or(Error { | |
| 496 | offset, | |
| 497 | message: "Chunk extends outside the data area", | |
| 498 | })?; | |
| 499 | crate::files::payload(blob, offset) | |
| 500 | } | |
| 528 | (Some((_, payload)), None) => *payload, | |
| 501 | 529 | _ => Err(Error { |
| 502 | 530 | offset: 0, |
| 503 | 531 | message: "Duplicate file-data identity", |
| ... | ... | @@ -706,6 +734,10 @@ impl<'a> Section<'a> { |
| 706 | 734 | } |
| 707 | 735 | self.broken = true; |
| 708 | 736 | let arena = &self.arena.0; |
| 737 | let protection = self | |
| 738 | .unlocked | |
| 739 | .as_ref() | |
| 740 | .map(|unlocked| unlocked as &dyn Protection); | |
| 709 | 741 | let mut appending = Appending::new(self.state.clone()); |
| 710 | 742 | let mut sealed = Vec::new(); |
| 711 | 743 | for (id, space) in &mut self.spaces { |
| ... | ... | @@ -726,8 +758,7 @@ impl<'a> Section<'a> { |
| 726 | 758 | created: &frozen.revised.created, |
| 727 | 759 | }, |
| 728 | 760 | &self.segments, |
| 729 | None, | |
| 730 | None, | |
| 761 | protection, | |
| 731 | 762 | &mut manifest, |
| 732 | 763 | )?; |
| 733 | 764 | } |
| ... | ... | @@ -757,8 +788,7 @@ impl<'a> Section<'a> { |
| 757 | 788 | created: &created, |
| 758 | 789 | }, |
| 759 | 790 | &self.segments, |
| 760 | None, | |
| 761 | None, | |
| 791 | protection, | |
| 762 | 792 | &mut manifest, |
| 763 | 793 | )?; |
| 764 | 794 | if commit.checkpoint { |
| ... | ... | @@ -805,8 +835,7 @@ impl<'a> Section<'a> { |
| 805 | 835 | created: &created, |
| 806 | 836 | }, |
| 807 | 837 | &self.segments, |
| 808 | None, | |
| 809 | None, | |
| 838 | protection, | |
| 810 | 839 | &mut manifest, |
| 811 | 840 | )?; |
| 812 | 841 | if commit.checkpoint { |
| ... | ... | @@ -834,7 +863,7 @@ impl<'a> Section<'a> { |
| 834 | 863 | .filter(|(guid, _)| !self.declared.contains(guid)) |
| 835 | 864 | .copied() |
| 836 | 865 | .collect(); |
| 837 | appending.payloads(&payloads, None)?; | |
| 866 | appending.payloads(&payloads, protection)?; | |
| 838 | 867 | #[cfg_attr(not(test), expect(unused_mut))] |
| 839 | 868 | let mut transaction = appending.finish()?; |
| 840 | 869 | #[cfg(test)] |
| ... | ... | @@ -892,6 +921,7 @@ impl<'a> Section<'a> { |
| 892 | 921 | &written, |
| 893 | 922 | &labels, |
| 894 | 923 | &payloads, |
| 924 | protection, | |
| 895 | 925 | )?; |
| 896 | 926 | let base = transaction.base.length; |
| 897 | 927 | let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append); |
| ... | ... | @@ -899,11 +929,20 @@ impl<'a> Section<'a> { |
| 899 | 929 | self.patches.extend(transaction.patches.iter().cloned()); |
| 900 | 930 | self.state = state.clone(); |
| 901 | 931 | self.declared.extend(payloads.iter().map(|(guid, _)| *guid)); |
| 902 | let bind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, crate::Chunk)]| { | |
| 932 | // An object now reads from where it is stored, or a protected one's clear bytes | |
| 933 | // from where they are. | |
| 934 | let unlocked = &self.unlocked; | |
| 935 | let rebind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, Chunk)]| { | |
| 903 | 936 | for (object_id, chunk) in stored { |
| 904 | 937 | let start = (chunk.offset - base) as usize; |
| 905 | revision.objects.get_mut(object_id).unwrap().data = | |
| 906 | ObjectData::Properties(&segment[start..start + chunk.length as usize]); | |
| 938 | let bytes = &segment[start..start + chunk.length as usize]; | |
| 939 | let data = &mut revision.objects.get_mut(object_id).unwrap().data; | |
| 940 | match (unlocked, *data) { | |
| 941 | (Some(unlocked), ObjectData::Properties(clear)) => { | |
| 942 | unlocked.sealed(clear, bytes) | |
| 943 | } | |
| 944 | _ => *data = ObjectData::Properties(bytes), | |
| 945 | } | |
| 907 | 946 | } |
| 908 | 947 | }; |
| 909 | 948 | for (id, page, history) in &mut sealed { |
| ... | ... | @@ -916,11 +955,11 @@ impl<'a> Section<'a> { |
| 916 | 955 | space.rid = Some(*rid); |
| 917 | 956 | space.restored = None; |
| 918 | 957 | space.newest = Some(*rid); |
| 919 | bind(&mut open.file.revision, stored); | |
| 958 | rebind(&mut open.file.revision, stored); | |
| 920 | 959 | } |
| 921 | 960 | if let Some((rid, stored, live, _)) = history.take() { |
| 922 | 961 | let mut revision = live.revision; |
| 923 | bind(&mut revision, &stored); | |
| 962 | rebind(&mut revision, &stored); | |
| 924 | 963 | space.history = Some(History { |
| 925 | 964 | rid, |
| 926 | 965 | revision, |
| ... | ... | @@ -969,7 +1008,11 @@ impl<'a> Section<'a> { |
| 969 | 1008 | } |
| 970 | 1009 | let mut image = self.image(); |
| 971 | 1010 | transaction.apply(&mut image)?; |
| 972 | *self = Self::open(self.arena, image)?; | |
| 1011 | let key = self | |
| 1012 | .unlocked | |
| 1013 | .as_ref() | |
| 1014 | .map(|unlocked| unlocked.key().clone()); | |
| 1015 | *self = Self::open_with(self.arena, vec![image], key.as_ref(), current)?; | |
| 973 | 1016 | Ok(()) |
| 974 | 1017 | } |
| 975 | 1018 | } |
| ... | ... | @@ -1022,37 +1065,50 @@ fn changed<'a>( |
| 1022 | 1065 | })) |
| 1023 | 1066 | } |
| 1024 | 1067 | |
| 1025 | /// `resolved`, parsed from `bytes`, borrowing them for their lifetime. | |
| 1026 | fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> { | |
| 1027 | ResolvedRevision { | |
| 1028 | roots: resolved.roots, | |
| 1029 | objects: resolved | |
| 1030 | .objects | |
| 1031 | .into_iter() | |
| 1032 | .map(|(id, object)| { | |
| 1033 | // The parse borrows `bytes` only as long as its store; its slices lie in | |
| 1034 | // `bytes`, which the arena keeps. | |
| 1035 | let data = match object.data { | |
| 1036 | ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)), | |
| 1037 | ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)), | |
| 1038 | ObjectData::File { | |
| 1039 | reference, | |
| 1040 | extension, | |
| 1041 | } => ObjectData::File { | |
| 1042 | reference: within(bytes, reference), | |
| 1043 | extension: within(bytes, extension), | |
| 1044 | }, | |
| 1045 | }; | |
| 1046 | let object = crate::Object { | |
| 1047 | jcid: object.jcid, | |
| 1048 | reference_count: object.reference_count, | |
| 1049 | data, | |
| 1050 | global_ids: object.global_ids, | |
| 1051 | }; | |
| 1052 | (id, object) | |
| 1053 | }) | |
| 1054 | .collect(), | |
| 1068 | /// `resolved` with every slice moved into bytes that live as long as the section by `bytes`, | |
| 1069 | /// a protected section's objects decoded under `unlocked`. | |
| 1070 | fn bind<'a>( | |
| 1071 | arena: &'a Arena, | |
| 1072 | unlocked: Option<&Opened<'a>>, | |
| 1073 | resolved: ResolvedRevision<'_>, | |
| 1074 | bytes: impl Fn(&[u8]) -> Result<&'a [u8]>, | |
| 1075 | ) -> Result<ResolvedRevision<'a>> { | |
| 1076 | let mut objects = BTreeMap::new(); | |
| 1077 | for (id, object) in resolved.objects { | |
| 1078 | let data = match (object.data, unlocked) { | |
| 1079 | (ObjectData::Properties(data), _) => ObjectData::Properties(bytes(data)?), | |
| 1080 | (ObjectData::Encrypted(data), Some(unlocked)) => { | |
| 1081 | ObjectData::Properties(unlocked.property(&arena.0, bytes(data)?)?) | |
| 1082 | } | |
| 1083 | (ObjectData::Encrypted(data), None) => ObjectData::Encrypted(bytes(data)?), | |
| 1084 | ( | |
| 1085 | ObjectData::File { | |
| 1086 | reference, | |
| 1087 | extension, | |
| 1088 | }, | |
| 1089 | _, | |
| 1090 | ) => ObjectData::File { | |
| 1091 | reference: bytes(reference)?, | |
| 1092 | extension: bytes(extension)?, | |
| 1093 | }, | |
| 1094 | }; | |
| 1095 | let object = crate::Object { | |
| 1096 | jcid: object.jcid, | |
| 1097 | reference_count: object.reference_count, | |
| 1098 | data, | |
| 1099 | global_ids: object.global_ids, | |
| 1100 | }; | |
| 1101 | objects.insert(id, object); | |
| 1055 | 1102 | } |
| 1103 | Ok(ResolvedRevision { | |
| 1104 | roots: resolved.roots, | |
| 1105 | objects, | |
| 1106 | }) | |
| 1107 | } | |
| 1108 | ||
| 1109 | /// The revision a space is current under. | |
| 1110 | fn current(_: &ExGuid, space: &crate::ObjectSpace<'_>) -> Option<ExGuid> { | |
| 1111 | space.labels.get(&(ExGuid::default(), 1)).copied() | |
| 1056 | 1112 | } |
| 1057 | 1113 | |
| 1058 | 1114 | /// `part`, a slice of `image`, with the image's lifetime. |
| ... | ... | @@ -1097,7 +1153,7 @@ mod tests { |
| 1097 | 1153 | edited: &[(ExGuid, &ResolvedRevision<'_>)], |
| 1098 | 1154 | payloads: &[([u8; 16], &[u8])], |
| 1099 | 1155 | ) -> Vec<u8> { |
| 1100 | let transaction = crate::write::squash(index, edited, payloads, None).unwrap(); | |
| 1156 | let transaction = crate::write::squash(index, edited, payloads).unwrap(); | |
| 1101 | 1157 | crate::write::applied(index.store.data, transaction.as_ref()).unwrap() |
| 1102 | 1158 | } |
| 1103 | 1159 |
crates/onestore/src/section/versions.rs+19-32| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | |
| 7 | 7 | use super::{Frozen, Open, Section, SpaceState, changed, one_page}; |
| 8 | 8 | use crate::{ |
| 9 | Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store, | |
| 9 | Error, ExGuid, FileType, Object, ResolvedRevision, RevisionIndex, Store, | |
| 10 | 10 | active::{ActivePage, manifest_pages}, |
| 11 | 11 | create::{author_properties, current_timestamps, properties}, |
| 12 | 12 | document::{Element, Kind, Revision}, |
| ... | ... | @@ -144,8 +144,13 @@ impl<'a> Section<'a> { |
| 144 | 144 | let image = self.image(); |
| 145 | 145 | let store = Store::parse(&image)?; |
| 146 | 146 | let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?; |
| 147 | let resolved = self.resident(&image, resolved)?; | |
| 147 | 148 | let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| { |
| 148 | store.file_data(guid) | |
| 149 | let stored = store.file_data(guid)?; | |
| 150 | match &self.unlocked { | |
| 151 | Some(unlocked) => unlocked.file(&self.arena.0, stored), | |
| 152 | None => Ok(stored), | |
| 153 | } | |
| 149 | 154 | })?; |
| 150 | 155 | one_page(&view, &manifest_pages(&view)) |
| 151 | 156 | } |
| ... | ... | @@ -258,8 +263,17 @@ impl<'a> Section<'a> { |
| 258 | 263 | let store = Store::parse(&image)?; |
| 259 | 264 | let index = RevisionIndex::parse(&store)?; |
| 260 | 265 | let rid = self.version_revision(&index, space, context)?; |
| 261 | let resolved = index.resolve(space, rid)?; | |
| 262 | // Object bytes lie in the section's segments, which outlive this image. | |
| 266 | let revision = self.resident(&image, index.resolve(space, rid)?)?; | |
| 267 | Ok((rid, revision, chain_depth(&index, space, rid))) | |
| 268 | } | |
| 269 | ||
| 270 | /// `resolved`, read from `image`, the section's sealed image, with its bytes in the | |
| 271 | /// section's segments, which outlive the image; a protected section's decoded. | |
| 272 | fn resident( | |
| 273 | &self, | |
| 274 | image: &[u8], | |
| 275 | resolved: ResolvedRevision<'_>, | |
| 276 | ) -> Result<ResolvedRevision<'a>> { | |
| 263 | 277 | let resident = |part: &[u8]| -> Result<&'a [u8]> { |
| 264 | 278 | let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64; |
| 265 | 279 | self.segments |
| ... | ... | @@ -275,34 +289,7 @@ impl<'a> Section<'a> { |
| 275 | 289 | message: "A version's bytes lie outside the section", |
| 276 | 290 | }) |
| 277 | 291 | }; |
| 278 | let mut objects = BTreeMap::new(); | |
| 279 | for (id, object) in resolved.objects { | |
| 280 | let data = match object.data { | |
| 281 | ObjectData::Properties(data) => ObjectData::Properties(resident(data)?), | |
| 282 | ObjectData::Encrypted(data) => ObjectData::Encrypted(resident(data)?), | |
| 283 | ObjectData::File { | |
| 284 | reference, | |
| 285 | extension, | |
| 286 | } => ObjectData::File { | |
| 287 | reference: resident(reference)?, | |
| 288 | extension: resident(extension)?, | |
| 289 | }, | |
| 290 | }; | |
| 291 | objects.insert( | |
| 292 | id, | |
| 293 | Object { | |
| 294 | jcid: object.jcid, | |
| 295 | reference_count: object.reference_count, | |
| 296 | data, | |
| 297 | global_ids: object.global_ids, | |
| 298 | }, | |
| 299 | ); | |
| 300 | } | |
| 301 | let revision = ResolvedRevision { | |
| 302 | roots: resolved.roots, | |
| 303 | objects, | |
| 304 | }; | |
| 305 | Ok((rid, revision, chain_depth(&index, space, rid))) | |
| 292 | super::bind(self.arena, self.unlocked.as_ref(), resolved, resident) | |
| 306 | 293 | } |
| 307 | 294 | |
| 308 | 295 | /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the |
crates/onestore/src/toc.rs+21-3| ... | ... | @@ -35,6 +35,12 @@ pub enum TocEdit { |
| 35 | 35 | Remove { |
| 36 | 36 | identity: [u8; 16], |
| 37 | 37 | }, |
| 38 | /// The entry's file took the identity `with`, as a section does whose password is set, | |
| 39 | /// changed or removed. | |
| 40 | Reidentify { | |
| 41 | identity: [u8; 16], | |
| 42 | with: [u8; 16], | |
| 43 | }, | |
| 38 | 44 | } |
| 39 | 45 | |
| 40 | 46 | fn component(name: &str) -> bool { |
| ... | ... | @@ -87,6 +93,7 @@ pub fn edit_table_of_contents( |
| 87 | 93 | stored.into_iter().map(|(_, entry)| entry).collect(); |
| 88 | 94 | let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new(); |
| 89 | 95 | let mut renamed = BTreeSet::new(); |
| 96 | let mut reidentified = BTreeSet::new(); | |
| 90 | 97 | let mut color = None; |
| 91 | 98 | for edit in edits { |
| 92 | 99 | let position = |identity: &[u8; 16]| { |
| ... | ... | @@ -178,6 +185,14 @@ pub fn edit_table_of_contents( |
| 178 | 185 | let (id, _, _) = listed.remove(at); |
| 179 | 186 | created.remove(&id); |
| 180 | 187 | } |
| 188 | TocEdit::Reidentify { identity, with } => { | |
| 189 | let at = position(identity)?; | |
| 190 | if *with == [0; 16] || listed.iter().any(|(_, known, _)| known == with) { | |
| 191 | return Err(invalid("The table of contents already lists that identity")); | |
| 192 | } | |
| 193 | listed[at].1 = *with; | |
| 194 | reidentified.insert(listed[at].0); | |
| 195 | } | |
| 181 | 196 | } |
| 182 | 197 | } |
| 183 | 198 | let listed = listed; |
| ... | ... | @@ -188,12 +203,12 @@ pub fn edit_table_of_contents( |
| 188 | 203 | TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. } |
| 189 | 204 | ) |
| 190 | 205 | }); |
| 191 | let transaction = build_on(&index, &[], None, |index| { | |
| 206 | let transaction = build_on(&index, &[], |index| { | |
| 192 | 207 | let raw = index.resolve_active(space)?; |
| 193 | 208 | let mut changed = BTreeMap::new(); |
| 194 | 209 | let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; |
| 195 | 210 | let mut references = Vec::new(); |
| 196 | for (order, (id, _, filename)) in listed.iter().enumerate() { | |
| 211 | for (order, (id, identity, filename)) in listed.iter().enumerate() { | |
| 197 | 212 | let mut object = match created.remove(id) { |
| 198 | 213 | Some(object) => object, |
| 199 | 214 | None => PropertyObject::from_object(&raw.objects[id])?, |
| ... | ... | @@ -213,6 +228,9 @@ pub fn edit_table_of_contents( |
| 213 | 228 | if renamed.contains(id) { |
| 214 | 229 | updates.push((0x1c001d6b, crate::create::string(filename))); |
| 215 | 230 | } |
| 231 | if reidentified.contains(id) { | |
| 232 | updates.push((0x1c001d94, identity.to_vec())); | |
| 233 | } | |
| 216 | 234 | let updates: Vec<(u32, &[u8])> = updates |
| 217 | 235 | .iter() |
| 218 | 236 | .map(|(id, bytes)| (*id, bytes.as_slice())) |
| ... | ... | @@ -287,7 +305,7 @@ mod tests { |
| 287 | 305 | let index = RevisionIndex::parse(&store).unwrap(); |
| 288 | 306 | let space = Document::parse(&index).unwrap().root; |
| 289 | 307 | let listed = entries(&toc); |
| 290 | let transaction = build_on(&index, &[], None, |index| { | |
| 308 | let transaction = build_on(&index, &[], |index| { | |
| 291 | 309 | let raw = index.resolve_active(space)?; |
| 292 | 310 | let mut changed = BTreeMap::new(); |
| 293 | 311 | for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) { |
crates/onestore/src/write.rs+175-121| ... | ... | @@ -487,19 +487,11 @@ pub(crate) struct PropertyObject { |
| 487 | 487 | |
| 488 | 488 | pub(crate) enum RevisionEdit { |
| 489 | 489 | Update(BTreeMap<ExGuid, PropertyObject>), |
| 490 | #[cfg(any(test, feature = "protected"))] | |
| 490 | #[cfg(test)] | |
| 491 | 491 | Create { |
| 492 | 492 | roots: BTreeMap<u32, ExGuid>, |
| 493 | 493 | objects: BTreeMap<ExGuid, PropertyObject>, |
| 494 | 494 | }, |
| 495 | /// A complete revision of an existing space under a context and role, without history. | |
| 496 | #[cfg(feature = "protected")] | |
| 497 | Label { | |
| 498 | context: ExGuid, | |
| 499 | role: u32, | |
| 500 | roots: BTreeMap<u32, ExGuid>, | |
| 501 | objects: BTreeMap<ExGuid, PropertyObject>, | |
| 502 | }, | |
| 503 | 495 | } |
| 504 | 496 | |
| 505 | 497 | impl PropertyObject { |
| ... | ... | @@ -801,14 +793,16 @@ impl PropertyObject { |
| 801 | 793 | } |
| 802 | 794 | } |
| 803 | 795 | |
| 804 | /// A password-protected section's unlocked view, through which its revisions are | |
| 805 | /// read as plaintext and written back in their stored form. | |
| 796 | /// A password-protected section's key, under which a writer stores what it appends. | |
| 806 | 797 | pub(crate) trait Protection { |
| 807 | fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result<crate::ResolvedRevision<'_>>; | |
| 808 | /// The stored bytes a resolved object's plaintext was decoded from. | |
| 798 | /// The stored bytes an object's clear bytes were decoded from. | |
| 809 | 799 | fn stored(&self, clear: &[u8]) -> Option<&[u8]>; |
| 810 | 800 | fn seal_property(&self, clear: &[u8]) -> Result<Vec<u8>>; |
| 811 | fn seal_file(&self, clear: &[u8]) -> Vec<u8>; | |
| 801 | fn seal_file(&self, clear: &[u8]) -> Result<Vec<u8>>; | |
| 802 | fn open_property(&self, stored: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>>; | |
| 803 | fn open_file(&self, stored: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>>; | |
| 804 | /// The encryption data's chunk, which every revision names. | |
| 805 | fn metadata(&self) -> Chunk; | |
| 812 | 806 | } |
| 813 | 807 | |
| 814 | 808 | /// The global-ID entries a group of objects declared together uses: each object's own |
| ... | ... | @@ -1281,7 +1275,7 @@ pub(crate) fn revisions( |
| 1281 | 1275 | let store = Store::parse(source)?; |
| 1282 | 1276 | let index = RevisionIndex::parse(&store)?; |
| 1283 | 1277 | index.validate_current()?; |
| 1284 | build_on(&index, &[], None, edit) | |
| 1278 | build_on(&index, &[], edit) | |
| 1285 | 1279 | } |
| 1286 | 1280 | |
| 1287 | 1281 | /// A test fixture: `source` with `edit`'s revisions appended. |
| ... | ... | @@ -1290,32 +1284,17 @@ pub(crate) fn write_revisions( |
| 1290 | 1284 | source: &[u8], |
| 1291 | 1285 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1292 | 1286 | ) -> Result<Vec<u8>> { |
| 1293 | publish(source, &[], None, true, edit) | |
| 1294 | } | |
| 1295 | ||
| 1296 | /// `source` with `edit`'s revisions and the `payloads` appended, without validating that | |
| 1297 | /// current revisions are complete: a protected section is validated by unlocking it, | |
| 1298 | /// through `protection`. | |
| 1299 | #[cfg(feature = "protected")] | |
| 1300 | pub(crate) fn append_revisions( | |
| 1301 | source: &[u8], | |
| 1302 | payloads: &[([u8; 16], &[u8])], | |
| 1303 | protection: Option<&dyn Protection>, | |
| 1304 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, | |
| 1305 | ) -> Result<Vec<u8>> { | |
| 1306 | publish(source, payloads, protection, false, edit) | |
| 1287 | publish(source, true, edit) | |
| 1307 | 1288 | } |
| 1308 | 1289 | |
| 1309 | #[cfg(any(test, feature = "protected"))] | |
| 1290 | #[cfg(test)] | |
| 1310 | 1291 | fn publish( |
| 1311 | 1292 | source: &[u8], |
| 1312 | payloads: &[([u8; 16], &[u8])], | |
| 1313 | protection: Option<&dyn Protection>, | |
| 1314 | 1293 | validate: bool, |
| 1315 | 1294 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1316 | 1295 | ) -> Result<Vec<u8>> { |
| 1317 | 1296 | // The parsed source is released before the result is parsed. |
| 1318 | let output = build(source, payloads, protection, validate, edit)?; | |
| 1297 | let output = build(source, validate, edit)?; | |
| 1319 | 1298 | check(&output, validate)?; |
| 1320 | 1299 | Ok(output) |
| 1321 | 1300 | } |
| ... | ... | @@ -1332,11 +1311,9 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> { |
| 1332 | 1311 | |
| 1333 | 1312 | /// The written image, unchecked: `check` follows once the caller has released whatever |
| 1334 | 1313 | /// `edit` borrowed. |
| 1335 | #[cfg(any(test, feature = "protected"))] | |
| 1314 | #[cfg(test)] | |
| 1336 | 1315 | fn build( |
| 1337 | 1316 | source: &[u8], |
| 1338 | payloads: &[([u8; 16], &[u8])], | |
| 1339 | protection: Option<&dyn Protection>, | |
| 1340 | 1317 | validate: bool, |
| 1341 | 1318 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1342 | 1319 | ) -> Result<Vec<u8>> { |
| ... | ... | @@ -1345,10 +1322,7 @@ fn build( |
| 1345 | 1322 | if validate { |
| 1346 | 1323 | index.validate_current()?; |
| 1347 | 1324 | } |
| 1348 | applied( | |
| 1349 | source, | |
| 1350 | build_on(&index, payloads, protection, edit)?.as_ref(), | |
| 1351 | ) | |
| 1325 | applied(source, build_on(&index, &[], edit)?.as_ref()) | |
| 1352 | 1326 | } |
| 1353 | 1327 | |
| 1354 | 1328 | /// `write_revision` on a source the caller has parsed and validated. |
| ... | ... | @@ -1360,7 +1334,7 @@ pub(crate) fn write_revision_on( |
| 1360 | 1334 | ) -> Result<Vec<u8>> { |
| 1361 | 1335 | let output = applied( |
| 1362 | 1336 | index.store.data, |
| 1363 | build_on(index, &[], None, update(space, edit))?.as_ref(), | |
| 1337 | build_on(index, &[], update(space, edit))?.as_ref(), | |
| 1364 | 1338 | )?; |
| 1365 | 1339 | check(&output, true)?; |
| 1366 | 1340 | Ok(output) |
| ... | ... | @@ -1377,7 +1351,6 @@ thread_local! { |
| 1377 | 1351 | pub(crate) fn build_on( |
| 1378 | 1352 | index: &RevisionIndex<'_>, |
| 1379 | 1353 | payloads: &[([u8; 16], &[u8])], |
| 1380 | protection: Option<&dyn Protection>, | |
| 1381 | 1354 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1382 | 1355 | ) -> Result<Option<crate::Transaction>> { |
| 1383 | 1356 | #[cfg(test)] |
| ... | ... | @@ -1391,10 +1364,6 @@ pub(crate) fn build_on( |
| 1391 | 1364 | message: "Cannot write a file with transaction checksum damage", |
| 1392 | 1365 | }); |
| 1393 | 1366 | } |
| 1394 | let resolve = |space, rid| match protection { | |
| 1395 | Some(protection) => protection.resolve(space, rid), | |
| 1396 | None => index.resolve(space, rid), | |
| 1397 | }; | |
| 1398 | 1367 | let changes = edit(index)?; |
| 1399 | 1368 | let mut appending = Appending::new(store.state()?); |
| 1400 | 1369 | for (space, change) in changes { |
| ... | ... | @@ -1402,9 +1371,15 @@ pub(crate) fn build_on( |
| 1402 | 1371 | let (rid, label, revision, replacements, new_space) = match change { |
| 1403 | 1372 | RevisionEdit::Update(objects) => { |
| 1404 | 1373 | let rid = index.active(space)?; |
| 1405 | (Some(rid), current, resolve(space, rid)?, objects, false) | |
| 1374 | ( | |
| 1375 | Some(rid), | |
| 1376 | current, | |
| 1377 | index.resolve(space, rid)?, | |
| 1378 | objects, | |
| 1379 | false, | |
| 1380 | ) | |
| 1406 | 1381 | } |
| 1407 | #[cfg(any(test, feature = "protected"))] | |
| 1382 | #[cfg(test)] | |
| 1408 | 1383 | RevisionEdit::Create { roots, objects } => { |
| 1409 | 1384 | if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { |
| 1410 | 1385 | return Err(Error { |
| ... | ... | @@ -1429,30 +1404,6 @@ pub(crate) fn build_on( |
| 1429 | 1404 | true, |
| 1430 | 1405 | ) |
| 1431 | 1406 | } |
| 1432 | #[cfg(feature = "protected")] | |
| 1433 | RevisionEdit::Label { | |
| 1434 | context, | |
| 1435 | role, | |
| 1436 | roots, | |
| 1437 | objects, | |
| 1438 | } => { | |
| 1439 | if !is_section || role > 0xffff || !index.spaces.contains_key(&space) { | |
| 1440 | return Err(Error { | |
| 1441 | offset: 0, | |
| 1442 | message: "Choose a label in a section's object space", | |
| 1443 | }); | |
| 1444 | } | |
| 1445 | ( | |
| 1446 | None, | |
| 1447 | (context, role), | |
| 1448 | crate::ResolvedRevision { | |
| 1449 | roots, | |
| 1450 | objects: BTreeMap::new(), | |
| 1451 | }, | |
| 1452 | objects, | |
| 1453 | false, | |
| 1454 | ) | |
| 1455 | } | |
| 1456 | 1407 | }; |
| 1457 | 1408 | let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); |
| 1458 | 1409 | let mut live = LiveRevision::new(revision, depth)?; |
| ... | ... | @@ -1478,25 +1429,6 @@ pub(crate) fn build_on( |
| 1478 | 1429 | }) |
| 1479 | 1430 | .collect::<Result<Vec<_>>>()?, |
| 1480 | 1431 | )?; |
| 1481 | // A dependent revision inherits its key, as OneNote writes it. | |
| 1482 | let key = if protection.is_some() && commit.checkpoint { | |
| 1483 | Some( | |
| 1484 | index | |
| 1485 | .spaces | |
| 1486 | .get(&space) | |
| 1487 | .and_then(|space| { | |
| 1488 | space.revisions.values().find_map(|revision| { | |
| 1489 | revision.nodes.first().filter(|node| node.id == 0x7c) | |
| 1490 | }) | |
| 1491 | }) | |
| 1492 | .ok_or(Error { | |
| 1493 | offset: 0, | |
| 1494 | message: "Protected revisions continue a protected object space", | |
| 1495 | })?, | |
| 1496 | ) | |
| 1497 | } else { | |
| 1498 | None | |
| 1499 | }; | |
| 1500 | 1432 | appending.revision( |
| 1501 | 1433 | &Sealing { |
| 1502 | 1434 | space, |
| ... | ... | @@ -1510,11 +1442,10 @@ pub(crate) fn build_on( |
| 1510 | 1442 | created: &created, |
| 1511 | 1443 | }, |
| 1512 | 1444 | &[(0, source)], |
| 1513 | protection, | |
| 1514 | key, | |
| 1445 | None, | |
| 1515 | 1446 | )?; |
| 1516 | 1447 | } |
| 1517 | appending.payloads(payloads, protection)?; | |
| 1448 | appending.payloads(payloads, None)?; | |
| 1518 | 1449 | Ok(appending.finish()?.map(|(transaction, _)| transaction)) |
| 1519 | 1450 | } |
| 1520 | 1451 | |
| ... | ... | @@ -1528,7 +1459,6 @@ pub(crate) fn applied(source: &[u8], transaction: Option<&crate::Transaction>) - |
| 1528 | 1459 | } |
| 1529 | 1460 | |
| 1530 | 1461 | /// Payload identities the file-data store of `store` declares, in order. |
| 1531 | #[cfg(any(test, feature = "protected"))] | |
| 1532 | 1462 | pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { |
| 1533 | 1463 | store |
| 1534 | 1464 | .lists |
| ... | ... | @@ -1541,13 +1471,12 @@ pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { |
| 1541 | 1471 | |
| 1542 | 1472 | /// Writes the live objects of each `edited` revision that differ from its space's active |
| 1543 | 1473 | /// revision in the validated `source` as one revision per space, embedding the `payloads` |
| 1544 | /// it lacks. A protected `source` takes the revisions its plaintext twin gained. | |
| 1545 | #[cfg(any(test, feature = "protected"))] | |
| 1474 | /// it lacks. | |
| 1475 | #[cfg(test)] | |
| 1546 | 1476 | pub(crate) fn squash( |
| 1547 | 1477 | source: &RevisionIndex<'_>, |
| 1548 | 1478 | edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], |
| 1549 | 1479 | payloads: &[([u8; 16], &[u8])], |
| 1550 | protection: Option<&dyn Protection>, | |
| 1551 | 1480 | ) -> Result<Option<crate::Transaction>> { |
| 1552 | 1481 | let existing = declared_payloads(source.store); |
| 1553 | 1482 | let payloads: Vec<_> = payloads |
| ... | ... | @@ -1555,13 +1484,10 @@ pub(crate) fn squash( |
| 1555 | 1484 | .filter(|(guid, _)| !existing.contains(guid)) |
| 1556 | 1485 | .copied() |
| 1557 | 1486 | .collect(); |
| 1558 | build_on(source, &payloads, protection, |index| { | |
| 1487 | build_on(source, &payloads, |index| { | |
| 1559 | 1488 | let mut changes = BTreeMap::new(); |
| 1560 | 1489 | for (space, after) in edited { |
| 1561 | let before = match protection { | |
| 1562 | Some(protection) => protection.resolve(*space, index.active(*space)?)?, | |
| 1563 | None => index.resolve_active(*space)?, | |
| 1564 | }; | |
| 1490 | let before = index.resolve_active(*space)?; | |
| 1565 | 1491 | if before.roots != after.roots { |
| 1566 | 1492 | return Err(Error { |
| 1567 | 1493 | offset: 0, |
| ... | ... | @@ -1590,6 +1516,111 @@ pub(crate) fn squash( |
| 1590 | 1516 | }) |
| 1591 | 1517 | } |
| 1592 | 1518 | |
| 1519 | /// A whole revision for `rewrite`, with the labels it is current under. | |
| 1520 | pub(crate) struct Labelled<'a> { | |
| 1521 | pub labels: Vec<(ExGuid, u32)>, | |
| 1522 | pub revision: crate::ResolvedRevision<'a>, | |
| 1523 | } | |
| 1524 | ||
| 1525 | /// `skeleton` (`create::skeleton`) with `spaces` appended, each revision a checkpoint under | |
| 1526 | /// its labels, and `payloads`, all stored under `key` when one is given. | |
| 1527 | pub(crate) fn rewrite( | |
| 1528 | skeleton: Vec<u8>, | |
| 1529 | spaces: &[(ExGuid, Vec<Labelled<'_>>)], | |
| 1530 | payloads: &[([u8; 16], &[u8])], | |
| 1531 | key: Option<&crate::protected::Key>, | |
| 1532 | ) -> Result<Vec<u8>> { | |
| 1533 | let store = Store::parse(&skeleton)?; | |
| 1534 | let state = store.state()?; | |
| 1535 | let declared: BTreeSet<ExGuid> = state.spaces.keys().copied().collect(); | |
| 1536 | let mut appending = Appending::new(state); | |
| 1537 | let opened = key | |
| 1538 | .map(|key| -> Result<_> { | |
| 1539 | let mut container = 0xfb6ba385dad1a067_u64.to_le_bytes().to_vec(); | |
| 1540 | container.extend_from_slice(key.metadata()); | |
| 1541 | container.extend_from_slice(&0x2649294f8e198b3c_u64.to_le_bytes()); | |
| 1542 | Ok(crate::protected::Opened::sealing( | |
| 1543 | key, | |
| 1544 | appending.append(&container)?, | |
| 1545 | )) | |
| 1546 | }) | |
| 1547 | .transpose()?; | |
| 1548 | let protection = opened.as_ref().map(|opened| opened as &dyn Protection); | |
| 1549 | for (space, revisions) in spaces { | |
| 1550 | let new = !declared.contains(space); | |
| 1551 | let mut manifest = Vec::new(); | |
| 1552 | let mut labels = Vec::new(); | |
| 1553 | for ( | |
| 1554 | index, | |
| 1555 | Labelled { | |
| 1556 | labels: names, | |
| 1557 | revision, | |
| 1558 | }, | |
| 1559 | ) in revisions.iter().enumerate() | |
| 1560 | { | |
| 1561 | let mut live = LiveRevision::new( | |
| 1562 | crate::ResolvedRevision { | |
| 1563 | roots: revision.roots.clone(), | |
| 1564 | objects: BTreeMap::new(), | |
| 1565 | }, | |
| 1566 | 0, | |
| 1567 | )?; | |
| 1568 | let commit = live.commit( | |
| 1569 | revision | |
| 1570 | .objects | |
| 1571 | .iter() | |
| 1572 | .map(|(id, object)| { | |
| 1573 | let mut object = object.clone(); | |
| 1574 | object.reference_count = 0; | |
| 1575 | (*id, object) | |
| 1576 | }) | |
| 1577 | .collect(), | |
| 1578 | )?; | |
| 1579 | let all: BTreeSet<ExGuid> = live.revision.objects.keys().copied().collect(); | |
| 1580 | let (first, others) = names.split_first().ok_or(Error { | |
| 1581 | offset: 0, | |
| 1582 | message: "A rewritten revision needs a label", | |
| 1583 | })?; | |
| 1584 | let (rid, _) = appending.manifest( | |
| 1585 | &Sealing { | |
| 1586 | space: *space, | |
| 1587 | previous: None, | |
| 1588 | new_space: new && index == 0, | |
| 1589 | label: *first, | |
| 1590 | rid: None, | |
| 1591 | live: &live, | |
| 1592 | commit: &commit, | |
| 1593 | replaced: &all, | |
| 1594 | created: &all, | |
| 1595 | }, | |
| 1596 | &[], | |
| 1597 | protection, | |
| 1598 | &mut manifest, | |
| 1599 | )?; | |
| 1600 | for (context, role) in others { | |
| 1601 | let mut payload = Vec::new(); | |
| 1602 | rid.encode(&mut payload); | |
| 1603 | payload.extend_from_slice(&role.to_le_bytes()); | |
| 1604 | let id = if *context == ExGuid::default() { | |
| 1605 | 0x5c | |
| 1606 | } else { | |
| 1607 | context.encode(&mut payload); | |
| 1608 | 0x5d | |
| 1609 | }; | |
| 1610 | labels.push(node(id, None, &payload)?); | |
| 1611 | } | |
| 1612 | } | |
| 1613 | manifest.extend(labels); | |
| 1614 | appending.close(*space, new, &manifest)?; | |
| 1615 | } | |
| 1616 | appending.payloads(payloads, protection)?; | |
| 1617 | let (transaction, _) = appending.finish()?.ok_or(Error { | |
| 1618 | offset: 0, | |
| 1619 | message: "A rewritten section holds nothing", | |
| 1620 | })?; | |
| 1621 | applied(&skeleton, Some(&transaction)) | |
| 1622 | } | |
| 1623 | ||
| 1593 | 1624 | /// A space's revision as `LiveRevision::commit` left it, ready to append. |
| 1594 | 1625 | pub(crate) struct Sealing<'r, 'a> { |
| 1595 | 1626 | pub space: ExGuid, |
| ... | ... | @@ -1664,7 +1695,7 @@ impl Appending { |
| 1664 | 1695 | self.base.length + self.append.len() as u64 |
| 1665 | 1696 | } |
| 1666 | 1697 | |
| 1667 | fn append(&mut self, bytes: &[u8]) -> Result<Chunk> { | |
| 1698 | pub(crate) fn append(&mut self, bytes: &[u8]) -> Result<Chunk> { | |
| 1668 | 1699 | let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { |
| 1669 | 1700 | offset: 0, |
| 1670 | 1701 | message: "Chunk exceeds the encoded length limit", |
| ... | ... | @@ -1743,10 +1774,9 @@ impl Appending { |
| 1743 | 1774 | sealing: &Sealing<'_, '_>, |
| 1744 | 1775 | segments: &[(u64, &[u8])], |
| 1745 | 1776 | protection: Option<&dyn Protection>, |
| 1746 | key: Option<&crate::Node<'_>>, | |
| 1747 | 1777 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { |
| 1748 | 1778 | let mut manifest = Vec::new(); |
| 1749 | let written = self.manifest(sealing, segments, protection, key, &mut manifest)?; | |
| 1779 | let written = self.manifest(sealing, segments, protection, &mut manifest)?; | |
| 1750 | 1780 | self.close(sealing.space, sealing.new_space, &manifest)?; |
| 1751 | 1781 | Ok(written) |
| 1752 | 1782 | } |
| ... | ... | @@ -1759,7 +1789,6 @@ impl Appending { |
| 1759 | 1789 | sealing: &Sealing<'_, '_>, |
| 1760 | 1790 | segments: &[(u64, &[u8])], |
| 1761 | 1791 | protection: Option<&dyn Protection>, |
| 1762 | key: Option<&crate::Node<'_>>, | |
| 1763 | 1792 | manifest: &mut Vec<Vec<u8>>, |
| 1764 | 1793 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { |
| 1765 | 1794 | let is_section = self.state.file_type == FileType::Section; |
| ... | ... | @@ -1863,12 +1892,14 @@ impl Appending { |
| 1863 | 1892 | None, |
| 1864 | 1893 | &start, |
| 1865 | 1894 | )?); |
| 1866 | if protection.is_some() && checkpoint { | |
| 1867 | let key = key.ok_or(Error { | |
| 1868 | offset: 0, | |
| 1869 | message: "Protected revisions continue a protected object space", | |
| 1870 | })?; | |
| 1871 | manifest.push(node(0x7c, key.reference, key.payload)?); | |
| 1895 | // MS-ONESTORE 2.5.19 has every revision of a protected space name its key; OneNote | |
| 1896 | // names it only in revisions without a dependency, and reads either. | |
| 1897 | if let Some(protection) = protection { | |
| 1898 | manifest.push(node( | |
| 1899 | 0x7c, | |
| 1900 | Some(Reference::Data(protection.metadata())), | |
| 1901 | &[], | |
| 1902 | )?); | |
| 1872 | 1903 | } |
| 1873 | 1904 | let mut stored = Vec::new(); |
| 1874 | 1905 | for (table, objects) in groups { |
| ... | ... | @@ -2084,7 +2115,9 @@ impl Appending { |
| 2084 | 2115 | 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, |
| 2085 | 2116 | 0x9e, 0xac, |
| 2086 | 2117 | ]; |
| 2087 | let sealed = protection.map(|protection| protection.seal_file(payload)); | |
| 2118 | let sealed = protection | |
| 2119 | .map(|protection| protection.seal_file(payload)) | |
| 2120 | .transpose()?; | |
| 2088 | 2121 | let payload = sealed.as_deref().unwrap_or(*payload); |
| 2089 | 2122 | blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); |
| 2090 | 2123 | blob.extend_from_slice(&[0; 12]); |
| ... | ... | @@ -2279,6 +2312,7 @@ pub(crate) struct Written<'r, 'a> { |
| 2279 | 2312 | /// object parses, names identities its group's table holds and objects reachable after its |
| 2280 | 2313 | /// revision, carries its incremental reference count and, if read-only, its MD5; roots stay |
| 2281 | 2314 | /// unless a space is new. Validates nothing the transaction leaves as it was. |
| 2315 | #[allow(clippy::too_many_arguments)] | |
| 2282 | 2316 | pub(crate) fn check_transaction( |
| 2283 | 2317 | before: &StoreState, |
| 2284 | 2318 | after: &StoreState, |
| ... | ... | @@ -2287,8 +2321,10 @@ pub(crate) fn check_transaction( |
| 2287 | 2321 | revisions: &[Written<'_, '_>], |
| 2288 | 2322 | labels: &[(ExGuid, ExGuid, ExGuid)], |
| 2289 | 2323 | payloads: &[([u8; 16], &[u8])], |
| 2324 | protection: Option<&dyn Protection>, | |
| 2290 | 2325 | ) -> Result<()> { |
| 2291 | 2326 | let wrong = |message| Error { offset: 0, message }; |
| 2327 | let encoding: [u8; 2] = if protection.is_some() { [2, 0] } else { [0, 0] }; | |
| 2292 | 2328 | let base = transaction.base.length; |
| 2293 | 2329 | // A chunk's bytes with this transaction's writes: nothing read here is patched earlier. |
| 2294 | 2330 | let read = |chunk: Chunk| -> Result<Vec<u8>> { |
| ... | ... | @@ -2449,13 +2485,20 @@ pub(crate) fn check_transaction( |
| 2449 | 2485 | || c.exguid()? != rid |
| 2450 | 2486 | || c.exguid()? != dependency |
| 2451 | 2487 | || c.read::<4>()? != label.1.to_le_bytes() |
| 2452 | || c.read::<2>()? != [0, 0] | |
| 2488 | || c.read::<2>()? != encoding | |
| 2453 | 2489 | || (contextual && c.exguid()? != label.0) |
| 2454 | 2490 | { |
| 2455 | 2491 | return Err(wrong( |
| 2456 | 2492 | "A revision starts with the wrong identity, dependency or label", |
| 2457 | 2493 | )); |
| 2458 | 2494 | } |
| 2495 | if let Some(protection) = protection { | |
| 2496 | let node = next()?; | |
| 2497 | if node.id != 0x7c || node.reference != Some(Reference::Data(protection.metadata())) | |
| 2498 | { | |
| 2499 | return Err(wrong("A protected revision does not name its key")); | |
| 2500 | } | |
| 2501 | } | |
| 2459 | 2502 | let mut declared = BTreeSet::new(); |
| 2460 | 2503 | let mut roots = BTreeMap::new(); |
| 2461 | 2504 | loop { |
| ... | ... | @@ -2513,10 +2556,14 @@ pub(crate) fn check_transaction( |
| 2513 | 2556 | return Err(wrong("An object declaration lacks its data")); |
| 2514 | 2557 | }; |
| 2515 | 2558 | let data = read(at)?; |
| 2559 | let clear = match protection { | |
| 2560 | Some(protection) => protection.open_property(&data)?, | |
| 2561 | None => zeroize::Zeroizing::new(data.clone()), | |
| 2562 | }; | |
| 2516 | 2563 | let stored = crate::Object { |
| 2517 | 2564 | jcid, |
| 2518 | 2565 | reference_count: count, |
| 2519 | data: ObjectData::Properties(&data), | |
| 2566 | data: ObjectData::Properties(&clear), | |
| 2520 | 2567 | global_ids: Arc::new(table.clone()), |
| 2521 | 2568 | }; |
| 2522 | 2569 | let references = stored.references()?; |
| ... | ... | @@ -2525,11 +2572,14 @@ pub(crate) fn check_transaction( |
| 2525 | 2572 | !references.object_spaces.is_empty() |
| 2526 | 2573 | || !references.contexts.is_empty(), |
| 2527 | 2574 | ) << 1); |
| 2528 | if object.data != ObjectData::Properties(&data) | |
| 2575 | let hash = match protection { | |
| 2576 | Some(_) => crate::protected::digest(&clear), | |
| 2577 | None => md5::compute(&data).0, | |
| 2578 | }; | |
| 2579 | if object.data != ObjectData::Properties(&clear) | |
| 2529 | 2580 | || flags != Some(expected) |
| 2530 | 2581 | || (item.id == 0xc5) != (jcid & 0x100000 != 0) |
| 2531 | || (item.id == 0xc5 | |
| 2532 | && c.read::<16>()? != md5::compute(&data).0) | |
| 2582 | || (item.id == 0xc5 && c.read::<16>()? != hash) | |
| 2533 | 2583 | { |
| 2534 | 2584 | return Err(wrong( |
| 2535 | 2585 | "An object's stored bytes differ from its revision", |
| ... | ... | @@ -2640,8 +2690,12 @@ pub(crate) fn check_transaction( |
| 2640 | 2690 | return Err(wrong("A payload declaration lacks its data")); |
| 2641 | 2691 | }; |
| 2642 | 2692 | let data = read(at)?; |
| 2643 | let blob = crate::files::payload(&data, at.offset as usize); | |
| 2644 | if node.id != 0x94 || node.payload != guid || blob.ok() != Some(*payload) { | |
| 2693 | let blob = crate::files::payload(&data, at.offset as usize)?; | |
| 2694 | let blob = match protection { | |
| 2695 | Some(protection) => protection.open_file(blob)?, | |
| 2696 | None => zeroize::Zeroizing::new(blob.to_vec()), | |
| 2697 | }; | |
| 2698 | if node.id != 0x94 || node.payload != guid || blob.as_slice() != *payload { | |
| 2645 | 2699 | return Err(wrong("An embedded payload differs from its declaration")); |
| 2646 | 2700 | } |
| 2647 | 2701 | } |
crates/onestore/tests/protected.rs+295-150| ... | ... | @@ -1,52 +1,54 @@ |
| 1 | #![cfg(feature = "protected")] | |
| 2 | ||
| 3 | 1 | use onestore::{ |
| 4 | RevisionIndex, Store, | |
| 2 | Arena, ExGuid, RevisionIndex, Section, Store, | |
| 5 | 3 | document::{Document, Kind}, |
| 6 | op::{Edit, Op, PageOp}, | |
| 7 | protected::{Error, Limits, UnlockedSection}, | |
| 4 | op::{Edit, Op}, | |
| 5 | page::Page, | |
| 6 | protected::{Error, Key, Limits, UnlockedSection, rekey}, | |
| 8 | 7 | }; |
| 9 | 8 | use std::{fs, path::Path}; |
| 10 | 9 | |
| 11 | #[test] | |
| 12 | fn known_passwords_open_independent_native_fixtures() { | |
| 13 | for (root, notebook, pages) in [ | |
| 14 | ("native-encrypted", "encrypted-01/notebook/synthetic.one", 1), | |
| 15 | ("native-protected-boundaries", "notebook/synthetic.one", 11), | |
| 16 | ] { | |
| 10 | /// The native fixtures with their passwords. | |
| 11 | fn fixtures() -> Vec<(Vec<u8>, String)> { | |
| 12 | [ | |
| 13 | ("native-encrypted", "encrypted-01/notebook/synthetic.one"), | |
| 14 | ("native-protected-boundaries", "notebook/synthetic.one"), | |
| 15 | ] | |
| 16 | .into_iter() | |
| 17 | .map(|(root, notebook)| { | |
| 17 | 18 | let root = Path::new("../../corpus").join(root); |
| 18 | 19 | let manifest: serde_json::Value = |
| 19 | 20 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); |
| 20 | let password = manifest["password"].as_str().unwrap(); | |
| 21 | let bytes = fs::read(root.join(notebook)).unwrap(); | |
| 22 | let before = bytes.clone(); | |
| 21 | ( | |
| 22 | fs::read(root.join(notebook)).unwrap(), | |
| 23 | manifest["password"].as_str().unwrap().to_owned(), | |
| 24 | ) | |
| 25 | }) | |
| 26 | .collect() | |
| 27 | } | |
| 28 | ||
| 29 | /// Every page of a section `Section::open` or `Section::unlock` reads, in order. | |
| 30 | fn pages(section: &mut Section<'_>) -> Vec<(ExGuid, Page)> { | |
| 31 | section | |
| 32 | .pages() | |
| 33 | .unwrap() | |
| 34 | .into_iter() | |
| 35 | .map(|(space, ..)| (space, section.page(space).unwrap())) | |
| 36 | .collect() | |
| 37 | } | |
| 38 | ||
| 39 | fn objects(pages: &[(ExGuid, Page)]) -> Vec<&Vec<onestore::page::PageObject>> { | |
| 40 | pages.iter().map(|(_, page)| &page.objects).collect() | |
| 41 | } | |
| 42 | ||
| 43 | #[test] | |
| 44 | fn known_passwords_open_independent_native_fixtures() { | |
| 45 | for ((bytes, password), count) in fixtures().into_iter().zip([1, 11]) { | |
| 23 | 46 | let store = Store::parse(&bytes).unwrap(); |
| 24 | 47 | let index = RevisionIndex::parse(&store).unwrap(); |
| 25 | 48 | assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); |
| 26 | let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); | |
| 49 | let unlocked = UnlockedSection::open(&index, &password, Limits::default()).unwrap(); | |
| 27 | 50 | let document = unlocked.document().unwrap(); |
| 28 | assert_eq!(document.pages().unwrap().len(), pages); | |
| 29 | let (space, _) = document.pages().unwrap()[0]; | |
| 30 | let current = &document.spaces[&space]; | |
| 31 | let revision = &current.revisions[&current.contexts[&onestore::ExGuid::default()]]; | |
| 32 | let (object, _) = revision | |
| 33 | .nodes | |
| 34 | .iter() | |
| 35 | .find(|(_, node)| matches!(node.kind, Kind::RichText { .. })) | |
| 36 | .unwrap(); | |
| 37 | // The ordinary section writer refuses a protected section. | |
| 38 | let arena = onestore::Arena::default(); | |
| 39 | assert!(onestore::Section::open(&arena, bytes.clone()).is_err()); | |
| 40 | let op = PageOp::Text { | |
| 41 | text: *object, | |
| 42 | range: 0..0, | |
| 43 | with: "Must remain protected".into(), | |
| 44 | }; | |
| 45 | let edit = Edit { | |
| 46 | at: 134_000_000_000_000_000, | |
| 47 | ops: vec![Op::Page { space, op }], | |
| 48 | }; | |
| 49 | assert!(unlocked.apply("Rust", &edit).is_ok()); | |
| 51 | assert_eq!(document.pages().unwrap().len(), count); | |
| 50 | 52 | assert!( |
| 51 | 53 | document |
| 52 | 54 | .spaces |
| ... | ... | @@ -55,26 +57,26 @@ fn known_passwords_open_independent_native_fixtures() { |
| 55 | 57 | .flat_map(|r| r.nodes.values()) |
| 56 | 58 | .all(|n| !matches!(n.kind, Kind::Encrypted { .. })) |
| 57 | 59 | ); |
| 60 | // A section opened under its key reads as the document does. | |
| 61 | let arena = Arena::default(); | |
| 62 | assert!(Section::open(&arena, bytes.clone()).is_err()); | |
| 63 | let key = Key::open(&bytes, &password).unwrap(); | |
| 64 | let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap(); | |
| 65 | let read = pages(&mut section); | |
| 66 | assert_eq!(read.len(), count); | |
| 67 | for (space, page) in &read { | |
| 68 | assert_eq!(*page, Page::from_space(&document, *space).unwrap()); | |
| 69 | } | |
| 58 | 70 | assert!(matches!( |
| 59 | UnlockedSection::open( | |
| 60 | &index, | |
| 61 | "deliberately incorrect fixture password", | |
| 62 | Limits::default() | |
| 63 | ), | |
| 71 | Key::open(&bytes, "deliberately incorrect fixture password"), | |
| 64 | 72 | Err(Error::PasswordMismatch) |
| 65 | 73 | )); |
| 66 | assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); | |
| 67 | assert_eq!(bytes, before); | |
| 68 | 74 | } |
| 69 | 75 | } |
| 70 | 76 | |
| 71 | 77 | #[test] |
| 72 | 78 | fn limits_and_password_bytes_are_explicit() { |
| 73 | let root = Path::new("../../corpus/native-protected-boundaries"); | |
| 74 | let manifest: serde_json::Value = | |
| 75 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); | |
| 76 | let password = manifest["password"].as_str().unwrap(); | |
| 77 | let bytes = fs::read(root.join("notebook/synthetic.one")).unwrap(); | |
| 79 | let (bytes, password) = fixtures().remove(1); | |
| 78 | 80 | let store = Store::parse(&bytes).unwrap(); |
| 79 | 81 | let index = RevisionIndex::parse(&store).unwrap(); |
| 80 | 82 | for limits in [ |
| ... | ... | @@ -92,7 +94,7 @@ fn limits_and_password_bytes_are_explicit() { |
| 92 | 94 | }, |
| 93 | 95 | ] { |
| 94 | 96 | assert!(matches!( |
| 95 | UnlockedSection::open(&index, password, limits), | |
| 97 | UnlockedSection::open(&index, &password, limits), | |
| 96 | 98 | Err(Error::Limit) |
| 97 | 99 | )); |
| 98 | 100 | } |
| ... | ... | @@ -102,111 +104,111 @@ fn limits_and_password_bytes_are_explicit() { |
| 102 | 104 | password.to_uppercase(), |
| 103 | 105 | ] { |
| 104 | 106 | assert!(matches!( |
| 105 | UnlockedSection::open(&index, &changed, Limits::default()), | |
| 107 | Key::open(&bytes, &changed), | |
| 106 | 108 | Err(Error::PasswordMismatch) |
| 107 | 109 | )); |
| 108 | 110 | } |
| 109 | 111 | assert!(matches!( |
| 110 | UnlockedSection::open(&index, &"x".repeat(65537), Limits::default()), | |
| 112 | Key::open(&bytes, &"x".repeat(65537)), | |
| 111 | 113 | Err(Error::Limit) |
| 112 | 114 | )); |
| 113 | 115 | let ordinary = onestore::create_section("ordinary.one", "Fictitious", "Author").unwrap(); |
| 114 | let store = Store::parse(&ordinary).unwrap(); | |
| 115 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 116 | 116 | assert!(matches!( |
| 117 | UnlockedSection::open(&index, password, Limits::default()), | |
| 117 | Key::open(&ordinary, &password), | |
| 118 | 118 | Err(Error::Unsupported) |
| 119 | 119 | )); |
| 120 | // A key opens only the section whose encryption data it came from. | |
| 121 | let other = Key::new(&password).unwrap(); | |
| 122 | assert!(matches!( | |
| 123 | Section::unlock(&Arena::default(), bytes, &other), | |
| 124 | Err(Error::PasswordMismatch) | |
| 125 | )); | |
| 120 | 126 | } |
| 121 | 127 | |
| 122 | /// The first text paragraph of every page gains text; the written file stays protected, | |
| 123 | /// opens with the same password and reads back as the edited model. | |
| 128 | /// Appends an attachment and text to the first text paragraph of `page`; none without one. | |
| 129 | fn edit(space: ExGuid, page: &Page) -> Option<(Edit, Page)> { | |
| 130 | use onestore::page::{Attachment, PageObject, Paragraph, ParagraphContent}; | |
| 131 | let mut edited = page.clone(); | |
| 132 | let paragraphs = edited.objects.iter_mut().find_map(|object| match object { | |
| 133 | PageObject::Outline(outline) if outline.paragraphs.iter().any(|p| p.text().is_some()) => { | |
| 134 | Some(&mut outline.paragraphs) | |
| 135 | } | |
| 136 | _ => None, | |
| 137 | })?; | |
| 138 | let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); | |
| 139 | let mut file = paragraphs[at].clone(); | |
| 140 | file.id = onestore::page::text::new_id().unwrap(); | |
| 141 | file.lists.clear(); | |
| 142 | file.tags.clear(); | |
| 143 | file.style = None; | |
| 144 | file.format = Default::default(); | |
| 145 | file.content = ParagraphContent::Attachment(Attachment { | |
| 146 | id: onestore::page::text::new_id().unwrap(), | |
| 147 | filename: "sealed.txt".into(), | |
| 148 | source_path: None, | |
| 149 | size: Some([24.0, 24.0]), | |
| 150 | layout: Default::default(), | |
| 151 | bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), | |
| 152 | preview: None, | |
| 153 | recording: None, | |
| 154 | tags: Vec::new(), | |
| 155 | }); | |
| 156 | paragraphs.insert(at + 1, file); | |
| 157 | let text = paragraphs[at].text_mut().unwrap(); | |
| 158 | let format = text.text.format_at(0).unwrap().clone(); | |
| 159 | text.text | |
| 160 | .append(Paragraph::new(" still protected".to_owned(), format)) | |
| 161 | .unwrap(); | |
| 162 | let ops = onestore::op::lower_page(page, &edited).unwrap(); | |
| 163 | let edit = Edit { | |
| 164 | at: 134_000_000_000_000_000, | |
| 165 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), | |
| 166 | }; | |
| 167 | Some((edit, edited)) | |
| 168 | } | |
| 169 | ||
| 170 | /// Every revision of every space of `bytes` names its key, as MS-ONESTORE 2.5.19 asks. | |
| 171 | fn keyed(bytes: &[u8]) -> bool { | |
| 172 | let store = Store::parse(bytes).unwrap(); | |
| 173 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 174 | index | |
| 175 | .spaces | |
| 176 | .values() | |
| 177 | .flat_map(|space| space.revisions.values()) | |
| 178 | .all(|revision| revision.encrypted && revision.nodes[0].id == 0x7c) | |
| 179 | } | |
| 180 | ||
| 181 | /// The first text paragraph of every page gains text and an attachment; each seal appends | |
| 182 | /// one revision to that page alone, stores none of it in the clear, and reads back as the | |
| 183 | /// edited model under the same password. | |
| 124 | 184 | #[test] |
| 125 | 185 | fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 126 | use onestore::page::{Page, PageObject, Paragraph}; | |
| 127 | for (root, notebook) in [ | |
| 128 | ("native-encrypted", "encrypted-01/notebook/synthetic.one"), | |
| 129 | ("native-protected-boundaries", "notebook/synthetic.one"), | |
| 130 | ] { | |
| 131 | let root = Path::new("../../corpus").join(root); | |
| 132 | let manifest: serde_json::Value = | |
| 133 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); | |
| 134 | let password = manifest["password"].as_str().unwrap(); | |
| 135 | let mut bytes = fs::read(root.join(notebook)).unwrap(); | |
| 136 | let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> { | |
| 137 | let store = Store::parse(bytes).unwrap(); | |
| 138 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 139 | assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); | |
| 140 | let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); | |
| 141 | let document = unlocked.document().unwrap(); | |
| 142 | document | |
| 143 | .pages() | |
| 144 | .unwrap() | |
| 145 | .into_iter() | |
| 146 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) | |
| 147 | .collect() | |
| 148 | }; | |
| 149 | let mut expected = pages(&bytes); | |
| 186 | for (mut bytes, password) in fixtures() { | |
| 187 | let key = Key::open(&bytes, &password).unwrap(); | |
| 188 | let mut expected = | |
| 189 | pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); | |
| 150 | 190 | let mut edited = 0; |
| 151 | 191 | for (space, page) in &mut expected { |
| 152 | let before = page.clone(); | |
| 153 | let paragraphs = page.objects.iter_mut().find_map(|object| match object { | |
| 154 | PageObject::Outline(outline) | |
| 155 | if outline.paragraphs.iter().any(|p| p.text().is_some()) => | |
| 156 | { | |
| 157 | Some(&mut outline.paragraphs) | |
| 158 | } | |
| 159 | _ => None, | |
| 160 | }); | |
| 161 | let Some(paragraphs) = paragraphs else { | |
| 192 | let Some((edit, after)) = edit(*space, page) else { | |
| 162 | 193 | continue; |
| 163 | 194 | }; |
| 195 | *page = after; | |
| 164 | 196 | edited += 1; |
| 165 | let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); | |
| 166 | let mut file = paragraphs[at].clone(); | |
| 167 | file.id = onestore::page::text::new_id().unwrap(); | |
| 168 | file.lists.clear(); | |
| 169 | file.tags.clear(); | |
| 170 | file.style = None; | |
| 171 | file.format = Default::default(); | |
| 172 | file.content = | |
| 173 | onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { | |
| 174 | id: onestore::page::text::new_id().unwrap(), | |
| 175 | filename: "sealed.txt".into(), | |
| 176 | source_path: None, | |
| 177 | size: Some([24.0, 24.0]), | |
| 178 | layout: Default::default(), | |
| 179 | bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), | |
| 180 | preview: None, | |
| 181 | recording: None, | |
| 182 | tags: Vec::new(), | |
| 183 | }); | |
| 184 | paragraphs.insert(at + 1, file); | |
| 185 | let text = paragraphs[at].text_mut().unwrap(); | |
| 186 | let format = text.text.format_at(0).unwrap().clone(); | |
| 187 | text.text | |
| 188 | .append(Paragraph::new(" still protected".to_owned(), format)) | |
| 189 | .unwrap(); | |
| 190 | let ops = onestore::op::lower_page(&before, page).unwrap(); | |
| 191 | let edit = Edit { | |
| 192 | at: 134_000_000_000_000_000, | |
| 193 | ops: ops | |
| 194 | .into_iter() | |
| 195 | .map(|op| Op::Page { space: *space, op }) | |
| 196 | .collect(), | |
| 197 | }; | |
| 198 | let store = Store::parse(&bytes).unwrap(); | |
| 199 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 200 | assert!(matches!( | |
| 201 | UnlockedSection::open(&index, "wrong", Limits::default()), | |
| 202 | Err(Error::PasswordMismatch) | |
| 203 | )); | |
| 204 | let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); | |
| 205 | let transaction = unlocked.apply("Rust", &edit).unwrap(); | |
| 197 | let arena = Arena::default(); | |
| 198 | let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap(); | |
| 199 | section.apply("Rust", &edit).unwrap(); | |
| 200 | let transaction = section.seal().unwrap().unwrap(); | |
| 206 | 201 | let mut written = bytes.clone(); |
| 207 | 202 | transaction.apply(&mut written).unwrap(); |
| 203 | assert_eq!(written, section.image()); | |
| 208 | 204 | for clear in [&b" still protected"[..], b"stays sealed"] { |
| 209 | assert!(!written.windows(clear.len()).any(|w| w == clear)); | |
| 205 | let utf16: Vec<u8> = String::from_utf8_lossy(clear) | |
| 206 | .encode_utf16() | |
| 207 | .flat_map(u16::to_le_bytes) | |
| 208 | .collect(); | |
| 209 | for clear in [clear, &utf16[..]] { | |
| 210 | assert!(!written.windows(clear.len()).any(|w| w == clear)); | |
| 211 | } | |
| 210 | 212 | } |
| 211 | 213 | let revisions = |bytes: &[u8]| { |
| 212 | 214 | let store = Store::parse(bytes).unwrap(); |
| ... | ... | @@ -224,13 +226,23 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 224 | 226 | .map(|(before, _)| before.0) |
| 225 | 227 | .collect(); |
| 226 | 228 | assert_eq!(grown, [*space]); |
| 229 | // The section kept open reads its own seal back. | |
| 230 | assert_eq!(section.page(*space).unwrap().objects, page.objects); | |
| 227 | 231 | bytes = written; |
| 228 | 232 | } |
| 229 | 233 | assert!(edited > 0); |
| 230 | let stored = pages(&bytes); | |
| 231 | assert_eq!(stored.len(), expected.len()); | |
| 232 | for ((_, stored), (_, expected)) in stored.iter().zip(&expected) { | |
| 233 | assert_eq!(stored.objects, expected.objects); | |
| 234 | let stored = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); | |
| 235 | assert_eq!(objects(&stored), objects(&expected)); | |
| 236 | // The document reader agrees, under the password alone. | |
| 237 | let store = Store::parse(&bytes).unwrap(); | |
| 238 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 239 | let unlocked = UnlockedSection::open(&index, &password, Limits::default()).unwrap(); | |
| 240 | let document = unlocked.document().unwrap(); | |
| 241 | for (space, page) in &expected { | |
| 242 | assert_eq!( | |
| 243 | Page::from_space(&document, *space).unwrap().objects, | |
| 244 | page.objects | |
| 245 | ); | |
| 234 | 246 | } |
| 235 | 247 | } |
| 236 | 248 | } |
| ... | ... | @@ -239,9 +251,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 239 | 251 | #[test] |
| 240 | 252 | fn a_native_revision_inherits_the_key_of_its_dependency() { |
| 241 | 253 | let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); |
| 242 | let manifest: serde_json::Value = | |
| 243 | serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap()) | |
| 244 | .unwrap(); | |
| 254 | let (_, password) = fixtures().remove(0); | |
| 245 | 255 | let store = Store::parse(&bytes).unwrap(); |
| 246 | 256 | let index = RevisionIndex::parse(&store).unwrap(); |
| 247 | 257 | assert!(index.spaces.values().any(|space| { |
| ... | ... | @@ -249,14 +259,149 @@ fn a_native_revision_inherits_the_key_of_its_dependency() { |
| 249 | 259 | revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) |
| 250 | 260 | }) |
| 251 | 261 | })); |
| 252 | let unlocked = UnlockedSection::open( | |
| 253 | &index, | |
| 254 | manifest["password"].as_str().unwrap(), | |
| 255 | Limits::default(), | |
| 256 | ) | |
| 257 | .unwrap(); | |
| 258 | let document = unlocked.document().unwrap(); | |
| 259 | let (space, _) = document.pages().unwrap()[0]; | |
| 260 | let page = onestore::page::Page::from_space(&document, space).unwrap(); | |
| 261 | assert!(format!("{:?}", page.objects).contains("Native edit after Rust.")); | |
| 262 | let key = Key::open(&bytes, &password).unwrap(); | |
| 263 | let read = pages(&mut Section::unlock(&Arena::default(), bytes, &key).unwrap()); | |
| 264 | assert!(format!("{:?}", read[0].1.objects).contains("Native edit after Rust.")); | |
| 265 | } | |
| 266 | ||
| 267 | /// Setting, changing and removing a password rewrites the section under new identities, | |
| 268 | /// keeping every page, version and payload; each password opens only its own image. | |
| 269 | #[test] | |
| 270 | fn a_password_is_set_changed_and_removed() { | |
| 271 | let plain = fs::read("../../corpus/page-versions/native/step-09/notebook/History.one").unwrap(); | |
| 272 | let arena = Arena::default(); | |
| 273 | let mut section = Section::open(&arena, plain.clone()).unwrap(); | |
| 274 | let before = pages(&mut section); | |
| 275 | let versions = section.versions().unwrap(); | |
| 276 | assert!(!versions.is_empty()); | |
| 277 | let identity = |bytes: &[u8]| { | |
| 278 | let store = Store::parse(bytes).unwrap(); | |
| 279 | ( | |
| 280 | store.header.file_id, | |
| 281 | RevisionIndex::parse(&store).unwrap().root, | |
| 282 | ) | |
| 283 | }; | |
| 284 | ||
| 285 | let first = Key::new("first password").unwrap(); | |
| 286 | let protected = rekey(&plain, None, Some(&first)).unwrap(); | |
| 287 | assert!(keyed(&protected)); | |
| 288 | assert_eq!(protected[128..148], plain[128..148]); | |
| 289 | assert_ne!(identity(&protected).0, identity(&plain).0); | |
| 290 | assert_ne!(identity(&protected).1, identity(&plain).1); | |
| 291 | assert!(Section::open(&Arena::default(), protected.clone()).is_err()); | |
| 292 | // The encryption data OneNote writes, which the password alone opens again. | |
| 293 | let reopened = Key::open(&protected, "first password").unwrap(); | |
| 294 | assert_eq!(reopened.secret(), first.secret()); | |
| 295 | assert!(matches!( | |
| 296 | Key::open(&protected, "First password"), | |
| 297 | Err(Error::PasswordMismatch) | |
| 298 | )); | |
| 299 | let arena = Arena::default(); | |
| 300 | let mut section = Section::unlock(&arena, protected.clone(), &reopened).unwrap(); | |
| 301 | assert_eq!(objects(&pages(&mut section)), objects(&before)); | |
| 302 | assert_eq!( | |
| 303 | section | |
| 304 | .versions() | |
| 305 | .unwrap() | |
| 306 | .iter() | |
| 307 | .map(|(_, versions)| versions.len()) | |
| 308 | .collect::<Vec<_>>(), | |
| 309 | versions | |
| 310 | .iter() | |
| 311 | .map(|(_, versions)| versions.len()) | |
| 312 | .collect::<Vec<_>>() | |
| 313 | ); | |
| 314 | let store = Store::parse(&protected).unwrap(); | |
| 315 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 316 | let unlocked = UnlockedSection::open(&index, "first password", Limits::default()).unwrap(); | |
| 317 | assert_eq!( | |
| 318 | unlocked.document().unwrap().pages().unwrap().len(), | |
| 319 | before.len() | |
| 320 | ); | |
| 321 | ||
| 322 | let second = Key::new("second password").unwrap(); | |
| 323 | assert!(matches!( | |
| 324 | rekey(&protected, None, Some(&second)), | |
| 325 | Err(Error::PasswordMismatch) | |
| 326 | )); | |
| 327 | let changed = rekey(&protected, Some(&first), Some(&second)).unwrap(); | |
| 328 | assert_ne!(first.secret(), second.secret()); | |
| 329 | assert!(matches!( | |
| 330 | Key::open(&changed, "first password"), | |
| 331 | Err(Error::PasswordMismatch) | |
| 332 | )); | |
| 333 | assert!(matches!( | |
| 334 | Section::unlock(&Arena::default(), changed.clone(), &first), | |
| 335 | Err(Error::PasswordMismatch) | |
| 336 | )); | |
| 337 | let arena = Arena::default(); | |
| 338 | let mut section = Section::unlock(&arena, changed.clone(), &second).unwrap(); | |
| 339 | assert_eq!(objects(&pages(&mut section)), objects(&before)); | |
| 340 | ||
| 341 | let removed = rekey(&changed, Some(&second), None).unwrap(); | |
| 342 | assert!( | |
| 343 | RevisionIndex::parse(&Store::parse(&removed).unwrap()) | |
| 344 | .unwrap() | |
| 345 | .spaces | |
| 346 | .values() | |
| 347 | .flat_map(|space| space.revisions.values()) | |
| 348 | .all(|revision| !revision.encrypted) | |
| 349 | ); | |
| 350 | let arena = Arena::default(); | |
| 351 | let mut section = Section::open(&arena, removed).unwrap(); | |
| 352 | assert_eq!(objects(&pages(&mut section)), objects(&before)); | |
| 353 | assert_eq!(section.versions().unwrap().len(), versions.len()); | |
| 354 | } | |
| 355 | ||
| 356 | /// Payloads survive a password of their own: every native attachment boundary length. | |
| 357 | #[test] | |
| 358 | fn payloads_survive_a_new_password() { | |
| 359 | let (bytes, password) = fixtures().remove(1); | |
| 360 | let key = Key::open(&bytes, &password).unwrap(); | |
| 361 | let before = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); | |
| 362 | let new = Key::new("another").unwrap(); | |
| 363 | let changed = rekey(&bytes, Some(&key), Some(&new)).unwrap(); | |
| 364 | let after = pages(&mut Section::unlock(&Arena::default(), changed, &new).unwrap()); | |
| 365 | assert_eq!(objects(&after), objects(&before)); | |
| 366 | } | |
| 367 | ||
| 368 | /// What OneNote 2010 wrote setting, changing and removing a password in the lab, and its edit | |
| 369 | /// of a section Snowbound protected, edited and gave another password, each read here. | |
| 370 | #[test] | |
| 371 | fn onenote_protected_changed_and_removed_sections_read_back() { | |
| 372 | let root = Path::new("../../corpus/protected-sections"); | |
| 373 | let manifest: serde_json::Value = | |
| 374 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); | |
| 375 | let plain = fs::read(root.join("source/synthetic.one")).unwrap(); | |
| 376 | let titles = |section: &mut Section<'_>| -> Vec<String> { | |
| 377 | section | |
| 378 | .pages() | |
| 379 | .unwrap() | |
| 380 | .into_iter() | |
| 381 | .map(|(_, title, _)| title) | |
| 382 | .collect() | |
| 383 | }; | |
| 384 | let expected = titles(&mut Section::open(&Arena::default(), plain).unwrap()); | |
| 385 | assert_eq!(expected.len(), 2); | |
| 386 | for (file, password) in manifest["native"].as_object().unwrap() { | |
| 387 | let bytes = fs::read(root.join("native").join(file)).unwrap(); | |
| 388 | let arena = Arena::default(); | |
| 389 | let mut section = match password.as_str() { | |
| 390 | Some(password) => { | |
| 391 | let key = Key::open(&bytes, password).unwrap(); | |
| 392 | assert!(Section::open(&Arena::default(), bytes.clone()).is_err()); | |
| 393 | Section::unlock(&arena, bytes, &key).unwrap() | |
| 394 | } | |
| 395 | None => Section::open(&arena, bytes).unwrap(), | |
| 396 | }; | |
| 397 | let read = titles(&mut section); | |
| 398 | assert_eq!(read.len(), 2, "{file}"); | |
| 399 | if file.starts_with("snowbound") { | |
| 400 | let (space, ..) = section.pages().unwrap()[0]; | |
| 401 | let page = format!("{:?}", section.page(space).unwrap().objects); | |
| 402 | assert!(page.contains("typed by OneNote"), "{file}"); | |
| 403 | } else { | |
| 404 | assert_eq!(read, expected, "{file}"); | |
| 405 | } | |
| 406 | } | |
| 262 | 407 | } |
crates/snowbound/Cargo.toml+2| ... | ... | @@ -31,6 +31,8 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r |
| 31 | 31 | # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. |
| 32 | 32 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } |
| 33 | 33 | ring = "0.17" |
| 34 | # Clears passwords typed into the password dialogs. | |
| 35 | zeroize = "1.9.0" | |
| 34 | 36 | |
| 35 | 37 | [target.'cfg(target_os = "macos")'.dependencies] |
| 36 | 38 | libc = "0.2" |
crates/snowbound/assets/icons/info.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" x2="0" y2="1"><stop offset="0" stop-color="#6fb5fb"/><stop offset="1" stop-color="#1b58b0"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M1 8.6A7 7 0 1 0 15 8.6A7 7 0 1 0 1 8.6Z" fill="#000000" fill-opacity="0.16"/> | |
| 6 | <path d="M1 8A7 7 0 1 0 15 8A7 7 0 1 0 1 8Z" fill="url(#g0)" stroke="#16489a" stroke-width="0.8"/> | |
| 7 | <path d="M3.6 6.4A4.8 4.8 0 0 1 12.4 6.4" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-opacity="0.55"/> | |
| 8 | <path d="M6.95 4.7A1.05 1.05 0 1 0 9.05 4.7A1.05 1.05 0 1 0 6.95 4.7Z" fill="#ffffff"/> | |
| 9 | <path d="M6.6 7H8.9V11.6H9.8V12.6H6.4V11.6H7.3V8H6.6Z" fill="#ffffff"/> | |
| 10 | </svg> |
crates/snowbound/assets/icons/warning.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="#ffe27a"/><stop offset="1" stop-color="#f5a31a"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M8 2.2L15 14.8H1Z" fill="#000000" fill-opacity="0.16" stroke="#000000" stroke-opacity="0.16" stroke-width="0.8" stroke-linejoin="round"/> | |
| 6 | <path d="M8 1.4L15 14H1Z" fill="url(#g0)" stroke="#b86e00" stroke-width="0.8" stroke-linejoin="round"/> | |
| 7 | <path d="M8 5.6V9.6M8 11.6V11.65" fill="none" stroke="#4a3000" stroke-width="1.5" stroke-linecap="round"/> | |
| 8 | </svg> |
crates/snowbound/linux/onenote.xml+1| ... | ... | @@ -5,5 +5,6 @@ |
| 5 | 5 | <alias type="application/msonenote"/> |
| 6 | 6 | <glob pattern="*.one"/> |
| 7 | 7 | <glob pattern="*.onetoc2"/> |
| 8 | <glob pattern="*.onepkg"/> | |
| 8 | 9 | </mime-type> |
| 9 | 10 | </mime-info> |
crates/snowbound/linux/package.sh+4-26| ... | ... | @@ -1,39 +1,17 @@ |
| 1 | 1 | #!/bin/sh |
| 2 | 2 | # Builds target/dist/snowbound-linux-ARCH.tar.gz for each ARCH given (x86_64, aarch64; |
| 3 | # both by default), cross-linking with zig so it runs from macOS or Linux. | |
| 3 | # both by default), cross-linking with zig (`platform/linux/cargo.sh`) so it runs from macOS or Linux. | |
| 4 | 4 | set -eu |
| 5 | 5 | root=$(cd "$(dirname "$0")/../../.." && pwd) |
| 6 | 6 | here="$root/crates/snowbound/linux" |
| 7 | 7 | target="$root/target" |
| 8 | tools="$target/zig" | |
| 9 | mkdir -p "$tools" "$target/dist" | |
| 10 | command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; } | |
| 11 | ||
| 12 | printf '#!/bin/sh\nexec zig ar "$@"\n' > "$tools/ar" | |
| 13 | chmod +x "$tools/ar" | |
| 8 | mkdir -p "$target/dist" | |
| 14 | 9 | |
| 15 | 10 | [ $# -gt 0 ] || set -- x86_64 aarch64 |
| 16 | 11 | for arch do |
| 17 | 12 | triple=$arch-unknown-linux-gnu |
| 18 | variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') | |
| 19 | linker="$tools/cc-$arch" | |
| 20 | # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig | |
| 21 | # rejects rustc's --target, --no-undefined-version and -O1. | |
| 22 | cat > "$linker" <<EOF | |
| 23 | #!/bin/sh | |
| 24 | for arg do | |
| 25 | shift | |
| 26 | case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac | |
| 27 | done | |
| 28 | exec zig cc -target $arch-linux-gnu.2.17 "\$@" | |
| 29 | EOF | |
| 30 | chmod +x "$linker" | |
| 31 | rustup target add "$triple" >/dev/null | |
| 32 | env "CARGO_TARGET_${variable}_LINKER=$linker" \ | |
| 33 | "CC_$(echo "$triple" | tr - _)=$linker" \ | |
| 34 | "AR_$(echo "$triple" | tr - _)=$tools/ar" \ | |
| 35 | CARGO_PROFILE_RELEASE_STRIP=symbols \ | |
| 36 | cargo build --manifest-path "$root/Cargo.toml" --release --target "$triple" -p snowbound | |
| 13 | CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \ | |
| 14 | --manifest-path "$root/Cargo.toml" --release -p snowbound | |
| 37 | 15 | |
| 38 | 16 | name=snowbound-linux-$arch |
| 39 | 17 | stage="$target/dist/$name" |
crates/snowbound/src/aqua.rs+4-21| ... | ... | @@ -17,10 +17,7 @@ use std::{ |
| 17 | 17 | sync::atomic::{AtomicBool, Ordering}, |
| 18 | 18 | }; |
| 19 | 19 | use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; |
| 20 | use winit::{ | |
| 21 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | |
| 22 | window::{CursorIcon, Window}, | |
| 23 | }; | |
| 20 | use winit::window::{CursorIcon, Window}; | |
| 24 | 21 | |
| 25 | 22 | static PRETEND: AtomicBool = AtomicBool::new(false); |
| 26 | 23 | |
| ... | ... | @@ -49,14 +46,8 @@ pub fn textured(window: &Window, row: f32) -> bool { |
| 49 | 46 | return false; |
| 50 | 47 | } |
| 51 | 48 | TEXTURED.store(true, Ordering::Relaxed); |
| 52 | let RawWindowHandle::AppKit(handle) = | |
| 53 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 54 | else { | |
| 55 | unreachable!() | |
| 56 | }; | |
| 49 | let window = crate::platform::ns_window(window); | |
| 57 | 50 | unsafe { |
| 58 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 59 | let window: Retained<AnyObject> = msg_send_id![view, window]; | |
| 60 | 51 | let title: Retained<AnyObject> = msg_send_id![&window, title]; |
| 61 | 52 | let mask: usize = msg_send![&window, styleMask]; |
| 62 | 53 | // NSTexturedBackgroundWindowMask. |
| ... | ... | @@ -450,16 +441,8 @@ pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { |
| 450 | 441 | } |
| 451 | 442 | static HIDDEN: std::sync::Once = std::sync::Once::new(); |
| 452 | 443 | HIDDEN.call_once(|| { |
| 453 | let RawWindowHandle::AppKit(handle) = | |
| 454 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 455 | else { | |
| 456 | unreachable!() | |
| 457 | }; | |
| 458 | unsafe { | |
| 459 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 460 | let window: Retained<AnyObject> = msg_send_id![view, window]; | |
| 461 | let _: () = msg_send![&window, setShowsResizeIndicator: false]; | |
| 462 | } | |
| 444 | let window = crate::platform::ns_window(window); | |
| 445 | let _: () = unsafe { msg_send![&window, setShowsResizeIndicator: false] }; | |
| 463 | 446 | }); |
| 464 | 447 | thread_local! { |
| 465 | 448 | static GRIP: draw::RasterImage = { |
crates/snowbound/src/art.rs+2| ... | ... | @@ -24,6 +24,7 @@ pub const ICLOUD: &[&str] = art!("icons/icloud"); |
| 24 | 24 | pub const FONT_COLOR: &[&str] = art!("icons/font-color"); |
| 25 | 25 | pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); |
| 26 | 26 | pub const INDENT: &[&str] = art!("icons/indent"); |
| 27 | pub const INFO: &[&str] = art!("icons/info"); | |
| 27 | 28 | pub const ITALIC: &[&str] = art!("icons/italic"); |
| 28 | 29 | pub const LINK: &[&str] = art!("icons/link"); |
| 29 | 30 | pub const NOTEBOOK: &[&str] = art!("icons/notebook"); |
| ... | ... | @@ -46,6 +47,7 @@ pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); |
| 46 | 47 | pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); |
| 47 | 48 | pub const TABLE: &[&str] = art!("icons/table"); |
| 48 | 49 | pub const UNDERLINE: &[&str] = art!("icons/underline"); |
| 50 | pub const WARNING: &[&str] = art!("icons/warning"); | |
| 49 | 51 | pub const UNDO: &[&str] = art!("icons/undo"); |
| 50 | 52 | pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); |
| 51 | 53 | pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); |
crates/snowbound/src/commands.rs+121-8| ... | ... | @@ -26,7 +26,22 @@ pub enum Id { |
| 26 | 26 | NewSubpage, |
| 27 | 27 | PageVersions, |
| 28 | 28 | CopyPageLink, |
| 29 | /// Password Protection on the section shown. | |
| 30 | PasswordProtect, | |
| 31 | /// Locks every password-protected section unlocked. | |
| 32 | LockAll, | |
| 29 | 33 | ShowNotebook, |
| 34 | /// Save As: the page, section or notebook as a OneNote section, package or PDF. | |
| 35 | SaveAs, | |
| 36 | /// The notebook's recycle bin, shown in place of its sections, or left. | |
| 37 | RecycleBin, | |
| 38 | EmptyRecycleBin, | |
| 39 | /// Marks the page shown read, or unread. | |
| 40 | MarkRead, | |
| 41 | MarkNotebookRead, | |
| 42 | /// Show Unread Changes in This Notebook. | |
| 43 | ShowUnread, | |
| 44 | NextUnread, | |
| 30 | 45 | ExportPdf, |
| 31 | 46 | Print, |
| 32 | 47 | Undo, |
| ... | ... | @@ -238,6 +253,18 @@ pub const COMMANDS: &[Command] = &[ |
| 238 | 253 | ), |
| 239 | 254 | row!(Id::PageVersions, "Page Versions", NONE, NONE), |
| 240 | 255 | row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE), |
| 256 | row!( | |
| 257 | Id::PasswordProtect, | |
| 258 | "Password Protect This Section…", | |
| 259 | NONE, | |
| 260 | NONE | |
| 261 | ), | |
| 262 | row!( | |
| 263 | Id::LockAll, | |
| 264 | "Lock All Sections", | |
| 265 | &[cmd('l').option()], | |
| 266 | &[cmd('l').option()] | |
| 267 | ), | |
| 241 | 268 | row!( |
| 242 | 269 | Id::ShowNotebook, |
| 243 | 270 | if cfg!(target_os = "macos") { |
| ... | ... | @@ -248,6 +275,24 @@ pub const COMMANDS: &[Command] = &[ |
| 248 | 275 | NONE, |
| 249 | 276 | NONE, |
| 250 | 277 | ), |
| 278 | row!( | |
| 279 | Id::SaveAs, | |
| 280 | "Save As…", | |
| 281 | &[cmd('s').shift()], | |
| 282 | &[cmd('s').shift()] | |
| 283 | ), | |
| 284 | row!(Id::RecycleBin, "Notebook Recycle Bin", NONE, NONE), | |
| 285 | row!(Id::EmptyRecycleBin, "Empty Recycle Bin", NONE, NONE), | |
| 286 | // OneNote 2010's Ctrl+Q; macOS keeps Command-Q for Quit. | |
| 287 | row!(Id::MarkRead, "Mark as Read", NONE, &[cmd('q')]), | |
| 288 | row!(Id::MarkNotebookRead, "Mark Notebook as Read", NONE, NONE), | |
| 289 | row!( | |
| 290 | Id::ShowUnread, | |
| 291 | "Show Unread Changes in This Notebook", | |
| 292 | NONE, | |
| 293 | NONE | |
| 294 | ), | |
| 295 | row!(Id::NextUnread, "Next Unread", NONE, NONE), | |
| 251 | 296 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), |
| 252 | 297 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. |
| 253 | 298 | row!( |
| ... | ... | @@ -1003,7 +1048,7 @@ impl State { |
| 1003 | 1048 | /// ⌘K, to the open palette's actions. |
| 1004 | 1049 | pub(crate) fn statuses(&self) -> Vec<Status> { |
| 1005 | 1050 | // A chord Options records reaches it past the menu bar's disabled items. |
| 1006 | if self.recording_chord() { | |
| 1051 | if (self.options.as_ref()).is_some_and(|options| options.keyboard.recording()) { | |
| 1007 | 1052 | return vec![Status::default(); choices().count()]; |
| 1008 | 1053 | } |
| 1009 | 1054 | let format = self.format_state(); |
| ... | ... | @@ -1020,6 +1065,7 @@ impl State { |
| 1020 | 1065 | let session = self.session.as_ref(); |
| 1021 | 1066 | let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); |
| 1022 | 1067 | let modal = self.options.is_some() |
| 1068 | || self.password.is_some() | |
| 1023 | 1069 | || self.themes.is_some() |
| 1024 | 1070 | || self.printing.open() |
| 1025 | 1071 | || self.link.is_some() |
| ... | ... | @@ -1071,6 +1117,39 @@ impl State { |
| 1071 | 1117 | | Id::Help |
| 1072 | 1118 | | Id::CheckForUpdates => enabled(!modal), |
| 1073 | 1119 | Id::CloseNotebook => enabled(!modal && self.notebook().is_some()), |
| 1120 | Id::PasswordProtect => enabled(!modal && self.shown_section().is_some()), | |
| 1121 | Id::SaveAs => enabled(!modal && self.notebook().is_some()), | |
| 1122 | Id::RecycleBin => Status { | |
| 1123 | enabled: !modal | |
| 1124 | && self | |
| 1125 | .notebook() | |
| 1126 | .is_some_and(|library| library.catalog().is_some()), | |
| 1127 | checked: Some(self.in_recycle_bin()), | |
| 1128 | }, | |
| 1129 | Id::EmptyRecycleBin => enabled( | |
| 1130 | !modal | |
| 1131 | && self | |
| 1132 | .notebook() | |
| 1133 | .is_some_and(|library| !library.tabs(crate::recycle::BIN).is_empty()), | |
| 1134 | ), | |
| 1135 | Id::MarkRead => Status { | |
| 1136 | enabled: !modal && session.is_some() && !self.in_recycle_bin(), | |
| 1137 | checked: Some(!self.page_unread()), | |
| 1138 | }, | |
| 1139 | Id::MarkNotebookRead | Id::NextUnread => enabled( | |
| 1140 | !modal | |
| 1141 | && self | |
| 1142 | .notebook() | |
| 1143 | .is_some_and(|library| self.unread_notebook(library)), | |
| 1144 | ), | |
| 1145 | Id::ShowUnread => Status { | |
| 1146 | enabled: !modal && self.notebook().is_some(), | |
| 1147 | checked: Some( | |
| 1148 | self.notebook() | |
| 1149 | .is_some_and(|library| self.reads.shown(&library.location)), | |
| 1150 | ), | |
| 1151 | }, | |
| 1152 | Id::LockAll => enabled(!modal), | |
| 1074 | 1153 | Id::ShowNotebook => enabled( |
| 1075 | 1154 | !modal |
| 1076 | 1155 | && self |
| ... | ... | @@ -1083,13 +1162,13 @@ impl State { |
| 1083 | 1162 | .notebook() |
| 1084 | 1163 | .is_some_and(|library| library.catalog().is_some()), |
| 1085 | 1164 | ), |
| 1086 | Id::NewPage | |
| 1087 | | Id::NewSubpage | |
| 1088 | | Id::CopyPageLink | |
| 1089 | | Id::Find | |
| 1090 | | Id::Search | |
| 1091 | | Id::ExportPdf | |
| 1092 | | Id::Print => enabled(!modal && session.is_some()), | |
| 1165 | // The recycle bin takes no new pages. | |
| 1166 | Id::NewPage | Id::NewSubpage => { | |
| 1167 | enabled(!modal && session.is_some() && !self.in_recycle_bin()) | |
| 1168 | } | |
| 1169 | Id::CopyPageLink | Id::Find | Id::Search | Id::ExportPdf | Id::Print => { | |
| 1170 | enabled(!modal && session.is_some()) | |
| 1171 | } | |
| 1093 | 1172 | Id::PageVersions => session.filter(|_| !modal).map_or( |
| 1094 | 1173 | Status { |
| 1095 | 1174 | enabled: false, |
| ... | ... | @@ -1338,6 +1417,40 @@ impl State { |
| 1338 | 1417 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); |
| 1339 | 1418 | return Ok(()); |
| 1340 | 1419 | } |
| 1420 | Id::PasswordProtect => { | |
| 1421 | let (library, path) = self.shown_section().ok_or("No section is open")?; | |
| 1422 | self.password_protection(library, path); | |
| 1423 | return Ok(()); | |
| 1424 | } | |
| 1425 | Id::LockAll => return self.lock_all(), | |
| 1426 | Id::SaveAs => { | |
| 1427 | let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); | |
| 1428 | let scope = if self.session.is_some() { | |
| 1429 | crate::save_as::Scope::Page | |
| 1430 | } else { | |
| 1431 | crate::save_as::Scope::Notebook | |
| 1432 | }; | |
| 1433 | self.open_save_as(library, None, scope); | |
| 1434 | return Ok(()); | |
| 1435 | } | |
| 1436 | Id::RecycleBin | Id::EmptyRecycleBin | Id::MarkNotebookRead | Id::ShowUnread => { | |
| 1437 | let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); | |
| 1438 | match id { | |
| 1439 | Id::RecycleBin => self.toggle_recycle_bin(library), | |
| 1440 | Id::EmptyRecycleBin => self.empty_recycle_bin(library), | |
| 1441 | Id::MarkNotebookRead => self.mark_notebook_read(&library), | |
| 1442 | _ => self.toggle_unread_shown(&library), | |
| 1443 | } | |
| 1444 | return Ok(()); | |
| 1445 | } | |
| 1446 | Id::MarkRead => { | |
| 1447 | self.toggle_read(); | |
| 1448 | return Ok(()); | |
| 1449 | } | |
| 1450 | Id::NextUnread => { | |
| 1451 | self.next_unread(); | |
| 1452 | return Ok(()); | |
| 1453 | } | |
| 1341 | 1454 | Id::ExportPdf | Id::Print => { |
| 1342 | 1455 | self.open_print(id == Id::ExportPdf); |
| 1343 | 1456 | return Ok(()); |
crates/snowbound/src/conflict_render.rs+1-1| ... | ... | @@ -64,7 +64,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { |
| 64 | 64 | &theme, |
| 65 | 65 | &section, |
| 66 | 66 | session, |
| 67 | &HashSet::new(), | |
| 67 | [&HashSet::new(); 2], | |
| 68 | 68 | (0.0, false), |
| 69 | 69 | None, |
| 70 | 70 | None, |
crates/snowbound/src/desktop_linux.rs+1-1| ... | ... | @@ -36,7 +36,7 @@ use winit::{ |
| 36 | 36 | /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. |
| 37 | 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; |
| 38 | 38 | const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); |
| 39 | /// The type of `.one` and `.onetoc2` files, which no shared-mime-info release names. | |
| 39 | /// The type of `.one`, `.onetoc2` and `.onepkg` files, which no shared-mime-info release names. | |
| 40 | 40 | const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); |
| 41 | 41 | /// 512 pixels square; the smaller sizes are scaled from it. |
| 42 | 42 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); |
crates/snowbound/src/history.rs+34-52| ... | ... | @@ -23,9 +23,12 @@ impl Session { |
| 23 | 23 | .map_or(&[], |(_, versions)| versions) |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | /// Whether the open page takes no edits: a conflict page or a version. | |
| 26 | /// Whether the open page takes no edits: a conflict page, a version, or a page of the | |
| 27 | /// recycle bin. | |
| 27 | 28 | pub(crate) fn read_only(&self) -> bool { |
| 28 | self.version.is_some() || self.conflict(self.space).is_some() | |
| 29 | self.version.is_some() | |
| 30 | || self.conflict(self.space).is_some() | |
| 31 | || crate::recycle::binned(&self.tabs[self.tab].path) | |
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | /// Version `version` of page `space` as the editor shows it: read-only, what it changed |
| ... | ... | @@ -166,6 +169,7 @@ impl State { |
| 166 | 169 | .section |
| 167 | 170 | .restore_version(page, version, &self.author)?; |
| 168 | 171 | session.refresh_history()?; |
| 172 | self.edited(vec![page]); | |
| 169 | 173 | self.commands.push(Command::OpenPage(page)); |
| 170 | 174 | Ok(()) |
| 171 | 175 | } |
| ... | ... | @@ -207,19 +211,7 @@ impl State { |
| 207 | 211 | ) { |
| 208 | 212 | return Ok(()); |
| 209 | 213 | } |
| 210 | let all: Vec<(ExGuid, Vec<ExGuid>)> = session | |
| 211 | .history | |
| 212 | .iter() | |
| 213 | .map(|(page, versions)| { | |
| 214 | ( | |
| 215 | *page, | |
| 216 | versions.iter().map(|version| version.context).collect(), | |
| 217 | ) | |
| 218 | }) | |
| 219 | .collect(); | |
| 220 | if !all.is_empty() { | |
| 221 | session.section.delete_versions(&all)?; | |
| 222 | } | |
| 214 | clear(&session.section)?; | |
| 223 | 215 | session.refresh_history()?; |
| 224 | 216 | if session.version.is_some() { |
| 225 | 217 | self.commands.push(Command::OpenPage(session.space)); |
| ... | ... | @@ -242,22 +234,7 @@ impl State { |
| 242 | 234 | let cleared = library |
| 243 | 235 | .open(&path, notify(proxy.clone())) |
| 244 | 236 | .and_then(|section| { |
| 245 | let all: Vec<(ExGuid, Vec<ExGuid>)> = section | |
| 246 | .versions()? | |
| 247 | .into_iter() | |
| 248 | .map(|(page, versions)| { | |
| 249 | ( | |
| 250 | page, | |
| 251 | versions | |
| 252 | .into_iter() | |
| 253 | .map(|version| version.context) | |
| 254 | .collect(), | |
| 255 | ) | |
| 256 | }) | |
| 257 | .collect(); | |
| 258 | if !all.is_empty() { | |
| 259 | section.delete_versions(&all)?; | |
| 260 | } | |
| 237 | clear(&section)?; | |
| 261 | 238 | Ok(section.close()?) |
| 262 | 239 | }); |
| 263 | 240 | if let Err(error) = cleared { |
| ... | ... | @@ -269,31 +246,36 @@ impl State { |
| 269 | 246 | } |
| 270 | 247 | } |
| 271 | 248 | |
| 249 | /// Deletes every page version `section` holds. | |
| 250 | fn clear(section: &notebook::session::Section) -> Result<(), notebook::Error> { | |
| 251 | let all: Vec<(ExGuid, Vec<ExGuid>)> = (section.versions()?.into_iter()) | |
| 252 | .map(|(page, versions)| { | |
| 253 | ( | |
| 254 | page, | |
| 255 | versions.iter().map(|version| version.context).collect(), | |
| 256 | ) | |
| 257 | }) | |
| 258 | .collect(); | |
| 259 | if !all.is_empty() { | |
| 260 | section.delete_versions(&all)?; | |
| 261 | } | |
| 262 | Ok(()) | |
| 263 | } | |
| 264 | ||
| 272 | 265 | /// The readable sections at catalog path `folder` and in the groups inside it, leaving out |
| 273 | 266 | /// the recycle bin. |
| 274 | 267 | fn sections(library: &Library, folder: &str) -> Vec<String> { |
| 275 | 268 | let Some(catalog) = library.catalog() else { |
| 276 | 269 | return vec![library.location.clone()]; |
| 277 | 270 | }; |
| 278 | let mut pending = vec![catalog]; | |
| 279 | let mut root = None; | |
| 280 | while let Some(candidate) = pending.pop() { | |
| 281 | if candidate.path == folder { | |
| 282 | root = Some(candidate); | |
| 283 | break; | |
| 284 | } | |
| 285 | pending.extend(&candidate.groups); | |
| 286 | } | |
| 287 | let mut pending: Vec<_> = root.into_iter().collect(); | |
| 288 | let mut paths = Vec::new(); | |
| 289 | while let Some(folder) = pending.pop() { | |
| 290 | paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path)); | |
| 291 | pending.extend( | |
| 292 | folder | |
| 293 | .groups | |
| 294 | .iter() | |
| 295 | .filter(|group| !library::recycle_bin(&group.path)), | |
| 296 | ); | |
| 297 | } | |
| 298 | paths | |
| 271 | let root = library::folders(catalog, |_| true) | |
| 272 | .into_iter() | |
| 273 | .find(|candidate| candidate.path == folder); | |
| 274 | root.map_or_else(Vec::new, |root| { | |
| 275 | library::folders(root, |group| !library::recycle_bin(&group.path)) | |
| 276 | }) | |
| 277 | .into_iter() | |
| 278 | .flat_map(|folder| library.tabs(&folder.path)) | |
| 279 | .map(|tab| tab.path) | |
| 280 | .collect() | |
| 299 | 281 | } |
crates/snowbound/src/icloud_linux.rs+5-1| ... | ... | @@ -25,9 +25,13 @@ pub fn section( |
| 25 | 25 | notebook: &Notebook, |
| 26 | 26 | _: &Path, |
| 27 | 27 | path: &str, |
| 28 | key: Option<&onestore::protected::Key>, | |
| 28 | 29 | notify: impl Fn() + Send + 'static, |
| 29 | 30 | ) -> Result<Section, notebook::Error> { |
| 30 | notebook.section(path, notify) | |
| 31 | match key { | |
| 32 | Some(key) => notebook.section_unlocked(path, key, notify), | |
| 33 | None => notebook.section(path, notify), | |
| 34 | } | |
| 31 | 35 | } |
| 32 | 36 | |
| 33 | 37 | pub fn lone_section( |
crates/snowbound/src/icloud_macos.rs+7-2| ... | ... | @@ -363,12 +363,17 @@ pub fn section( |
| 363 | 363 | notebook: &Notebook, |
| 364 | 364 | root: &Path, |
| 365 | 365 | path: &str, |
| 366 | key: Option<&onestore::protected::Key>, | |
| 366 | 367 | notify: impl Fn() + Send + 'static, |
| 367 | 368 | ) -> Result<Section, notebook::Error> { |
| 368 | 369 | if !notebook.replica_path(path)?.exists() { |
| 369 | 370 | fetch(&root.join(path))?; |
| 370 | 371 | } |
| 371 | let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?; | |
| 372 | let connect = |file: &Path| Ok(Coordinated(file.to_owned())); | |
| 373 | let section = match key { | |
| 374 | Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, | |
| 375 | None => notebook.section_with(path, connect, notify)?, | |
| 376 | }; | |
| 372 | 377 | section.set_pause(PAUSE); |
| 373 | 378 | Ok(section) |
| 374 | 379 | } |
| ... | ... | @@ -576,7 +581,7 @@ mod tests { |
| 576 | 581 | let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); |
| 577 | 582 | assert!(ubiquitous(&root)); |
| 578 | 583 | let path = notebook.catalog().sections[0].path.clone(); |
| 579 | let section = section(&notebook, &root, &path, || {}).unwrap(); | |
| 584 | let section = section(&notebook, &root, &path, None, || {}).unwrap(); | |
| 580 | 585 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 581 | 586 | let title = section |
| 582 | 587 | .page(space) |
crates/snowbound/src/keys.rs+1-8| ... | ... | @@ -78,7 +78,7 @@ fn label(chord: Chord) -> String { |
| 78 | 78 | fn title(id: Command) -> String { |
| 79 | 79 | match id { |
| 80 | 80 | Command::Tag(place) => format!("Tag {}", place + 1), |
| 81 | id => commands::command(id).title.trim_end_matches('…').to_owned(), | |
| 81 | id => crate::title(id).to_owned(), | |
| 82 | 82 | } |
| 83 | 83 | } |
| 84 | 84 | |
| ... | ... | @@ -430,13 +430,6 @@ impl crate::State { |
| 430 | 430 | taken |
| 431 | 431 | } |
| 432 | 432 | |
| 433 | /// Whether Options is recording a chord, which the menu bar must then leave to it. | |
| 434 | pub(crate) fn recording_chord(&self) -> bool { | |
| 435 | self.options | |
| 436 | .as_ref() | |
| 437 | .is_some_and(|options| options.keyboard.recording()) | |
| 438 | } | |
| 439 | ||
| 440 | 433 | /// Puts `keymap` in use, and in the menu bar where it changes. |
| 441 | 434 | pub(crate) fn install_keymap(&self, keymap: Keymap) { |
| 442 | 435 | if keymap.install() { |
crates/snowbound/src/library.rs+214-136| ... | ... | @@ -5,6 +5,7 @@ use notebook::discover::{Folder, SectionState}; |
| 5 | 5 | use notebook::session::{Background, Notebook, Section}; |
| 6 | 6 | use notebook::sidecar::themes::Themes; |
| 7 | 7 | use notebook::smb::{Client, Credentials}; |
| 8 | use onestore::protected::Key; | |
| 8 | 9 | use std::{ |
| 9 | 10 | error::Error, |
| 10 | 11 | io, |
| ... | ... | @@ -151,14 +152,14 @@ impl Mount { |
| 151 | 152 | let mut url = String::from("smb://"); |
| 152 | 153 | if let Some(user) = &self.user { |
| 153 | 154 | if !self.domain.is_empty() { |
| 154 | url += &format!("{};", encode(&self.domain)); | |
| 155 | url += &format!("{};", encode(self.domain.as_bytes())); | |
| 155 | 156 | } |
| 156 | url += &format!("{}@", encode(user)); | |
| 157 | url += &format!("{}@", encode(user.as_bytes())); | |
| 157 | 158 | } |
| 158 | 159 | url += &self.server; |
| 159 | 160 | for part in [&self.share, &self.root] { |
| 160 | 161 | if !part.is_empty() { |
| 161 | url += &format!("/{}", encode(part)); | |
| 162 | url += &format!("/{}", encode(part.as_bytes())); | |
| 162 | 163 | } |
| 163 | 164 | } |
| 164 | 165 | url |
| ... | ... | @@ -232,10 +233,11 @@ fn decode(text: &str) -> String { |
| 232 | 233 | String::from_utf8_lossy(&decoded).into_owned() |
| 233 | 234 | } |
| 234 | 235 | |
| 235 | /// `text` escaped for an address, `/` kept. | |
| 236 | fn encode(text: &str) -> String { | |
| 237 | text.bytes() | |
| 238 | .map(|byte| match byte { | |
| 236 | /// `bytes` escaped for an address, outside RFC 3986's unreserved set and `/`. | |
| 237 | pub(crate) fn encode(bytes: &[u8]) -> String { | |
| 238 | bytes | |
| 239 | .iter() | |
| 240 | .map(|&byte| match byte { | |
| 239 | 241 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { |
| 240 | 242 | char::from(byte).to_string() |
| 241 | 243 | } |
| ... | ... | @@ -318,9 +320,34 @@ pub struct Library { |
| 318 | 320 | kept: Mutex<crate::prefetch::Recent<String, Section>>, |
| 319 | 321 | /// The notebook's style themes, and when they were read. |
| 320 | 322 | themes: Mutex<Option<(Instant, Arc<Themes>)>>, |
| 323 | /// The keys of its password-protected sections unlocked this run, kept as it is read again. | |
| 324 | keys: Arc<Keys>, | |
| 321 | 325 | } |
| 322 | 326 | |
| 327 | /// Keys of password-protected sections, by file identity, with when each section was last | |
| 328 | /// worked in; in memory only, each cleared as it goes. | |
| 329 | #[derive(Default)] | |
| 330 | pub struct Keys(Mutex<std::collections::HashMap<[u8; 16], (Key, Instant)>>); | |
| 331 | ||
| 323 | 332 | impl Library { |
| 333 | /// `location` named `name`, with no notebook read, server, sync or kept sections. | |
| 334 | fn new(location: &str, name: String, cache: &Path) -> Self { | |
| 335 | Self { | |
| 336 | location: location.to_owned(), | |
| 337 | name, | |
| 338 | notebook: Ok(None), | |
| 339 | server: None, | |
| 340 | notice: None, | |
| 341 | cache: cache.to_owned(), | |
| 342 | background: None, | |
| 343 | watch: None, | |
| 344 | tag_art: Default::default(), | |
| 345 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 346 | themes: Default::default(), | |
| 347 | keys: Default::default(), | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 324 | 351 | /// The notebook in the folder at `location`, or why it cannot be read. A folder on a |
| 325 | 352 | /// mounted SMB share opens through Snowbound's own SMB client, signed in with the |
| 326 | 353 | /// account the system keeps for the mount, as OneNote's own client coordinates with |
| ... | ... | @@ -333,18 +360,12 @@ impl Library { |
| 333 | 360 | }; |
| 334 | 361 | return login |
| 335 | 362 | .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) |
| 336 | .unwrap_or_else(|reason| Self { | |
| 337 | location: location.to_owned(), | |
| 338 | name: display_name(cache, location).unwrap_or_else(|| mount.name()), | |
| 339 | notebook: Err(reason), | |
| 340 | server: None, | |
| 341 | notice: None, | |
| 342 | cache: cache.to_owned(), | |
| 343 | background: None, | |
| 344 | watch: None, | |
| 345 | tag_art: Default::default(), | |
| 346 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 347 | themes: Default::default(), | |
| 363 | .unwrap_or_else(|reason| { | |
| 364 | let name = display_name(cache, location).unwrap_or_else(|| mount.name()); | |
| 365 | Self { | |
| 366 | notebook: Err(reason), | |
| 367 | ..Self::new(location, name, cache) | |
| 368 | } | |
| 348 | 369 | }); |
| 349 | 370 | } |
| 350 | 371 | let mut notice = None; |
| ... | ... | @@ -364,20 +385,16 @@ impl Library { |
| 364 | 385 | Ok(notebook) => local_background(notebook, location), |
| 365 | 386 | Err(_) => (None, None), |
| 366 | 387 | }; |
| 388 | let name = display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))); | |
| 367 | 389 | Self { |
| 368 | location: location.to_owned(), | |
| 369 | name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))), | |
| 370 | 390 | background, |
| 371 | 391 | watch, |
| 372 | 392 | tag_art: Mutex::new(Arc::new( |
| 373 | 393 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), |
| 374 | 394 | )), |
| 375 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 376 | themes: Default::default(), | |
| 377 | 395 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 378 | server: None, | |
| 379 | 396 | notice, |
| 380 | cache: cache.to_owned(), | |
| 397 | ..Self::new(location, name, cache) | |
| 381 | 398 | } |
| 382 | 399 | } |
| 383 | 400 | |
| ... | ... | @@ -405,21 +422,17 @@ impl Library { |
| 405 | 422 | .map_err(|error| error.to_string())?; |
| 406 | 423 | background.set_offline(offline()); |
| 407 | 424 | background.watch(notebook.replicas()); |
| 408 | Ok(Self { | |
| 409 | location: location.to_owned(), | |
| 410 | name: display_name(cache, location).unwrap_or_else(|| match server_address(location) { | |
| 425 | let name = | |
| 426 | display_name(cache, location).unwrap_or_else(|| match server_address(location) { | |
| 411 | 427 | Some(_) => server.mount.name(), |
| 412 | 428 | None => file_name(Path::new(location)), |
| 413 | }), | |
| 429 | }); | |
| 430 | Ok(Self { | |
| 414 | 431 | tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))), |
| 415 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 416 | themes: Default::default(), | |
| 417 | 432 | notebook: Ok(Some(notebook)), |
| 418 | 433 | server: Some(server), |
| 419 | notice: None, | |
| 420 | cache: cache.to_owned(), | |
| 421 | 434 | background: Some(Arc::new(background)), |
| 422 | watch: None, | |
| 435 | ..Self::new(location, name, cache) | |
| 423 | 436 | }) |
| 424 | 437 | } |
| 425 | 438 | |
| ... | ... | @@ -576,18 +589,16 @@ impl Library { |
| 576 | 589 | } |
| 577 | 590 | _ => self.watch.clone(), |
| 578 | 591 | }; |
| 592 | let name = display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()); | |
| 579 | 593 | Self { |
| 580 | location: self.location.clone(), | |
| 581 | name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()), | |
| 582 | 594 | notebook: Ok(Some(notebook)), |
| 583 | 595 | server: self.server.clone(), |
| 584 | 596 | notice: self.notice.clone(), |
| 585 | cache: self.cache.clone(), | |
| 586 | 597 | background: self.background.clone(), |
| 587 | 598 | watch, |
| 588 | 599 | tag_art: Mutex::new(self.tag_art()), |
| 589 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 590 | themes: Default::default(), | |
| 600 | keys: Arc::clone(&self.keys), | |
| 601 | ..Self::new(&self.location, name, &self.cache) | |
| 591 | 602 | } |
| 592 | 603 | } |
| 593 | 604 | |
| ... | ... | @@ -595,35 +606,20 @@ impl Library { |
| 595 | 606 | pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { |
| 596 | 607 | let (background, watch) = local_background(&mut notebook, location); |
| 597 | 608 | Self { |
| 598 | location: location.to_owned(), | |
| 599 | name: file_name(Path::new(location)), | |
| 600 | 609 | background, |
| 601 | 610 | watch, |
| 602 | tag_art: Default::default(), | |
| 603 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 604 | themes: Default::default(), | |
| 605 | 611 | notebook: Ok(Some(notebook)), |
| 606 | server: None, | |
| 607 | notice: None, | |
| 608 | cache: cache.to_owned(), | |
| 612 | ..Self::new(location, file_name(Path::new(location)), cache) | |
| 609 | 613 | } |
| 610 | 614 | } |
| 611 | 615 | |
| 612 | 616 | /// The section file at `file` as a notebook of one tab. |
| 613 | 617 | pub fn section(file: &Path, cache: &Path) -> Self { |
| 614 | Self { | |
| 615 | location: file.to_string_lossy().into_owned(), | |
| 616 | name: file_name(file.parent().unwrap_or(file)), | |
| 617 | notebook: Ok(None), | |
| 618 | server: None, | |
| 619 | notice: None, | |
| 620 | cache: cache.to_owned(), | |
| 621 | background: None, | |
| 622 | watch: None, | |
| 623 | tag_art: Default::default(), | |
| 624 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 625 | themes: Default::default(), | |
| 626 | } | |
| 618 | Self::new( | |
| 619 | &file.to_string_lossy(), | |
| 620 | file_name(file.parent().unwrap_or(file)), | |
| 621 | cache, | |
| 622 | ) | |
| 627 | 623 | } |
| 628 | 624 | |
| 629 | 625 | /// Opens the section at catalog `path`; on a share, through a replica named by its |
| ... | ... | @@ -654,6 +650,11 @@ impl Library { |
| 654 | 650 | } |
| 655 | 651 | } |
| 656 | 652 | }; |
| 653 | let key = self.unlocked(path); | |
| 654 | self.touch(path); | |
| 655 | if key.is_none() && self.protected(path) { | |
| 656 | return Err("The section is password protected".into()); | |
| 657 | } | |
| 657 | 658 | let section = loop { |
| 658 | 659 | if let Some(kept) = self |
| 659 | 660 | .kept |
| ... | ... | @@ -671,7 +672,7 @@ impl Library { |
| 671 | 672 | }; |
| 672 | 673 | let cache = notebook.replica_path(path)?; |
| 673 | 674 | std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; |
| 674 | let replica = notebook::Replica::open_or_create(&cache, || { | |
| 675 | let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || { | |
| 675 | 676 | Ok(server.connect()?.read_storage(&file, LIMIT)?) |
| 676 | 677 | }); |
| 677 | 678 | replica.and_then(|replica| { |
| ... | ... | @@ -681,9 +682,13 @@ impl Library { |
| 681 | 682 | }) |
| 682 | 683 | } |
| 683 | 684 | (Ok(Some(notebook)), None) if self.in_icloud() => { |
| 684 | crate::icloud::section(notebook, Path::new(&self.location), path, notifier()) | |
| 685 | let root = Path::new(&self.location); | |
| 686 | crate::icloud::section(notebook, root, path, key.as_ref(), notifier()) | |
| 685 | 687 | } |
| 686 | (Ok(Some(notebook)), None) => notebook.section(path, notifier()), | |
| 688 | (Ok(Some(notebook)), None) => match &key { | |
| 689 | Some(key) => notebook.section_unlocked(path, key, notifier()), | |
| 690 | None => notebook.section(path, notifier()), | |
| 691 | }, | |
| 687 | 692 | (Ok(None), _) if self.in_icloud() => { |
| 688 | 693 | crate::icloud::lone_section(Path::new(path), &self.cache, notifier()) |
| 689 | 694 | } |
| ... | ... | @@ -835,16 +840,10 @@ impl Library { |
| 835 | 840 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. |
| 836 | 841 | pub fn tabs(&self, folder: &str) -> Vec<Tab> { |
| 837 | 842 | match &self.notebook { |
| 838 | Ok(Some(notebook)) => { | |
| 839 | let mut folders = vec![notebook.catalog()]; | |
| 840 | while let Some(candidate) = folders.pop() { | |
| 841 | if candidate.path == folder { | |
| 842 | return tabs(candidate); | |
| 843 | } | |
| 844 | folders.extend(&candidate.groups); | |
| 845 | } | |
| 846 | Vec::new() | |
| 847 | } | |
| 843 | Ok(Some(notebook)) => folders(notebook.catalog(), |_| true) | |
| 844 | .into_iter() | |
| 845 | .find(|candidate| candidate.path == folder) | |
| 846 | .map_or_else(Vec::new, tabs), | |
| 848 | 847 | Ok(None) => vec![Tab { |
| 849 | 848 | path: self.location.clone(), |
| 850 | 849 | name: section_name(&self.location, &None), |
| ... | ... | @@ -856,38 +855,23 @@ impl Library { |
| 856 | 855 | |
| 857 | 856 | /// The file identity of the section at catalog `path`. |
| 858 | 857 | pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { |
| 859 | let Ok(Some(notebook)) = &self.notebook else { | |
| 860 | return None; | |
| 861 | }; | |
| 862 | let mut folders = vec![notebook.catalog()]; | |
| 863 | while let Some(folder) = folders.pop() { | |
| 864 | if let Some(section) = folder.sections.iter().find(|section| section.path == path) { | |
| 865 | return Some(section.file_id); | |
| 866 | } | |
| 867 | folders.extend(&folder.groups); | |
| 868 | } | |
| 869 | None | |
| 858 | folders(self.catalog()?, |_| true) | |
| 859 | .into_iter() | |
| 860 | .flat_map(|folder| &folder.sections) | |
| 861 | .find(|section| section.path == path) | |
| 862 | .map(|section| section.file_id) | |
| 870 | 863 | } |
| 871 | 864 | |
| 872 | 865 | /// The first readable section, searching groups after sections, as OneNote opens a |
| 873 | 866 | /// notebook. |
| 874 | 867 | pub fn first_section(&self) -> Option<String> { |
| 875 | let Ok(Some(notebook)) = &self.notebook else { | |
| 868 | let Some(catalog) = self.catalog() else { | |
| 876 | 869 | return self.tabs("").pop().map(|tab| tab.path); |
| 877 | 870 | }; |
| 878 | let mut folders = std::collections::VecDeque::from([notebook.catalog()]); | |
| 879 | while let Some(folder) = folders.pop_front() { | |
| 880 | if let Some(tab) = tabs(folder).into_iter().next() { | |
| 881 | return Some(tab.path); | |
| 882 | } | |
| 883 | folders.extend( | |
| 884 | folder | |
| 885 | .groups | |
| 886 | .iter() | |
| 887 | .filter(|group| !recycle_bin(&group.path)), | |
| 888 | ); | |
| 889 | } | |
| 890 | None | |
| 871 | folders(catalog, |group| !recycle_bin(&group.path)) | |
| 872 | .into_iter() | |
| 873 | .find_map(|folder| tabs(folder).into_iter().next()) | |
| 874 | .map(|tab| tab.path) | |
| 891 | 875 | } |
| 892 | 876 | |
| 893 | 877 | /// Whether the notebook lists a section at catalog path `path`. |
| ... | ... | @@ -895,14 +879,9 @@ impl Library { |
| 895 | 879 | let Some(catalog) = self.catalog() else { |
| 896 | 880 | return self.location == path; |
| 897 | 881 | }; |
| 898 | let mut folders = vec![catalog]; | |
| 899 | while let Some(folder) = folders.pop() { | |
| 900 | if folder.sections.iter().any(|section| section.path == path) { | |
| 901 | return true; | |
| 902 | } | |
| 903 | folders.extend(&folder.groups); | |
| 904 | } | |
| 905 | false | |
| 882 | folders(catalog, |_| true) | |
| 883 | .iter() | |
| 884 | .any(|folder| folder.sections.iter().any(|section| section.path == path)) | |
| 906 | 885 | } |
| 907 | 886 | |
| 908 | 887 | /// The notebook's colour, COLORREF, as its table of contents holds it. |
| ... | ... | @@ -913,21 +892,12 @@ impl Library { |
| 913 | 892 | /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps |
| 914 | 893 | /// them elsewhere. |
| 915 | 894 | pub fn downloading(&self) -> bool { |
| 916 | let Some(catalog) = self.catalog() else { | |
| 917 | return false; | |
| 918 | }; | |
| 919 | let mut folders = vec![catalog]; | |
| 920 | while let Some(folder) = folders.pop() { | |
| 921 | if folder | |
| 922 | .unavailable | |
| 923 | .iter() | |
| 924 | .any(|entry| entry.reason == notebook::discover::Reason::Evicted) | |
| 925 | { | |
| 926 | return true; | |
| 927 | } | |
| 928 | folders.extend(&folder.groups); | |
| 929 | } | |
| 930 | false | |
| 895 | self.catalog().is_some_and(|catalog| { | |
| 896 | folders(catalog, |_| true).iter().any(|folder| { | |
| 897 | (folder.unavailable.iter()) | |
| 898 | .any(|entry| entry.reason == notebook::discover::Reason::Evicted) | |
| 899 | }) | |
| 900 | }) | |
| 931 | 901 | } |
| 932 | 902 | |
| 933 | 903 | /// The notebook's folders of sections, for the sidebar. |
| ... | ... | @@ -937,6 +907,89 @@ impl Library { |
| 937 | 907 | _ => None, |
| 938 | 908 | } |
| 939 | 909 | } |
| 910 | ||
| 911 | /// Whether the section at catalog `path` is password protected. | |
| 912 | pub fn protected(&self, path: &str) -> bool { | |
| 913 | self.catalog().is_some_and(|catalog| { | |
| 914 | folders(catalog, |_| true) | |
| 915 | .into_iter() | |
| 916 | .flat_map(|folder| &folder.sections) | |
| 917 | .any(|section| { | |
| 918 | section.path == path && matches!(section.state, SectionState::Locked) | |
| 919 | }) | |
| 920 | }) | |
| 921 | } | |
| 922 | ||
| 923 | /// Whether the section at catalog `path` is password protected and not unlocked. | |
| 924 | pub fn locked(&self, path: &str) -> bool { | |
| 925 | self.protected(path) && self.unlocked(path).is_none() | |
| 926 | } | |
| 927 | ||
| 928 | /// The key the section at catalog `path` was unlocked with. | |
| 929 | pub fn unlocked(&self, path: &str) -> Option<Key> { | |
| 930 | let identity = self.section_identity(path)?; | |
| 931 | let keys = self.keys.0.lock().ok()?; | |
| 932 | keys.get(&identity).map(|(key, _)| key.clone()) | |
| 933 | } | |
| 934 | ||
| 935 | /// Marks the unlocked section at catalog `path` worked in now. | |
| 936 | pub fn touch(&self, path: &str) { | |
| 937 | if let (Some(identity), Ok(mut keys)) = (self.section_identity(path), self.keys.0.lock()) | |
| 938 | && let Some((_, used)) = keys.get_mut(&identity) | |
| 939 | { | |
| 940 | *used = Instant::now(); | |
| 941 | } | |
| 942 | } | |
| 943 | ||
| 944 | /// Unlocks the section at catalog `path` with `password`, as OneNote's Protected Section | |
| 945 | /// dialog does; a wrong password is `PasswordMismatch`. | |
| 946 | pub fn unlock(&self, path: &str, password: &str) -> Result<(), notebook::Error> { | |
| 947 | let Ok(Some(notebook)) = &self.notebook else { | |
| 948 | return Err(io::Error::from(io::ErrorKind::NotFound).into()); | |
| 949 | }; | |
| 950 | let key = notebook.unlock(path, password)?; | |
| 951 | self.keep_key(path, key); | |
| 952 | Ok(()) | |
| 953 | } | |
| 954 | ||
| 955 | /// Keeps `key` for the section at catalog `path`, as setting its password gives one. | |
| 956 | pub fn keep_key(&self, path: &str, key: Key) { | |
| 957 | if let (Some(identity), Ok(mut keys)) = (self.section_identity(path), self.keys.0.lock()) { | |
| 958 | keys.insert(identity, (key, Instant::now())); | |
| 959 | } | |
| 960 | } | |
| 961 | ||
| 962 | /// Locks the protected sections unlocked here that `chosen` picks by catalog path and | |
| 963 | /// time since last worked in, closing the kept sessions that hold them; returns the paths | |
| 964 | /// locked. | |
| 965 | pub fn lock(&self, mut chosen: impl FnMut(&str, Duration) -> bool) -> Vec<String> { | |
| 966 | let Some(catalog) = self.catalog() else { | |
| 967 | return Vec::new(); | |
| 968 | }; | |
| 969 | let mut locked = Vec::new(); | |
| 970 | if let Ok(mut keys) = self.keys.0.lock() { | |
| 971 | for section in folders(catalog, |_| true) | |
| 972 | .into_iter() | |
| 973 | .flat_map(|folder| &folder.sections) | |
| 974 | { | |
| 975 | let stale = keys | |
| 976 | .get(&section.file_id) | |
| 977 | .is_some_and(|(_, used)| chosen(&section.path, used.elapsed())); | |
| 978 | if stale { | |
| 979 | keys.remove(&section.file_id); | |
| 980 | locked.push(section.path.clone()); | |
| 981 | } | |
| 982 | } | |
| 983 | } | |
| 984 | if let Ok(mut kept) = self.kept.lock() { | |
| 985 | for path in &locked { | |
| 986 | if let Some(section) = kept.take(path) { | |
| 987 | std::thread::spawn(move || close(vec![section])); | |
| 988 | } | |
| 989 | } | |
| 990 | } | |
| 991 | locked | |
| 992 | } | |
| 940 | 993 | } |
| 941 | 994 | |
| 942 | 995 | /// The share folder a notebook opened from its server's address lives in, as its location |
| ... | ... | @@ -948,7 +1001,6 @@ pub fn server_address(location: &str) -> Option<Mount> { |
| 948 | 1001 | .flatten() |
| 949 | 1002 | } |
| 950 | 1003 | |
| 951 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. | |
| 952 | 1004 | /// `Library::key` for the notebook at `location`, open or not. |
| 953 | 1005 | pub fn key(location: &str, path: &str) -> String { |
| 954 | 1006 | format!("{location}\n{path}") |
| ... | ... | @@ -971,13 +1023,28 @@ fn display_name(cache: &Path, location: &str) -> Option<String> { |
| 971 | 1023 | read_display_names(&display_names(cache)).remove(location) |
| 972 | 1024 | } |
| 973 | 1025 | |
| 1026 | /// `folder` and the groups below it, breadth first, going into those `enter` takes. | |
| 1027 | pub fn folders(folder: &Folder, enter: impl Fn(&Folder) -> bool) -> Vec<&Folder> { | |
| 1028 | let mut all = vec![folder]; | |
| 1029 | let mut at = 0; | |
| 1030 | while let Some(folder) = all.get(at) { | |
| 1031 | all.extend(folder.groups.iter().filter(|group| enter(group))); | |
| 1032 | at += 1; | |
| 1033 | } | |
| 1034 | all | |
| 1035 | } | |
| 1036 | ||
| 1037 | /// A section's or group's name, as its catalog path ends. | |
| 1038 | pub fn entry_name(path: &str) -> &str { | |
| 1039 | let name = path.rsplit('/').next().unwrap_or(path); | |
| 1040 | name.strip_suffix(".one").unwrap_or(name) | |
| 1041 | } | |
| 1042 | ||
| 1043 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. | |
| 974 | 1044 | pub fn recycle_bin(path: &str) -> bool { |
| 975 | 1045 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| 976 | 1046 | } |
| 977 | 1047 | |
| 978 | /// A mounted notebook's background sync, following Work Offline, and the watch that reports | |
| 979 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a | |
| 980 | /// share, and where the system does not report its server's changes, checked more often. | |
| 981 | 1048 | /// The art `notebook` maps its tags to, each picture read once a run. |
| 982 | 1049 | fn read_tag_art(notebook: &Notebook) -> TagArt { |
| 983 | 1050 | let mut read = TagArt::default(); |
| ... | ... | @@ -999,6 +1066,9 @@ fn read_tag_art(notebook: &Notebook) -> TagArt { |
| 999 | 1066 | read |
| 1000 | 1067 | } |
| 1001 | 1068 | |
| 1069 | /// A mounted notebook's background sync, following Work Offline, and the watch that reports | |
| 1070 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a | |
| 1071 | /// share, and where the system does not report its server's changes, checked more often. | |
| 1002 | 1072 | fn local_background( |
| 1003 | 1073 | notebook: &mut Notebook, |
| 1004 | 1074 | location: &str, |
| ... | ... | @@ -1061,6 +1131,10 @@ pub fn file_name(path: &Path) -> String { |
| 1061 | 1131 | /// A section's name: its display name, or its file's. |
| 1062 | 1132 | pub fn section_name(path: &str, name: &Option<String>) -> String { |
| 1063 | 1133 | name.clone().unwrap_or_else(|| { |
| 1134 | // OneNote names the recycle bin's file of deleted pages so. | |
| 1135 | if path.ends_with("OneNote_RecycleBin/OneNote_DeletedPages.one") { | |
| 1136 | return "Deleted Pages".to_owned(); | |
| 1137 | } | |
| 1064 | 1138 | Path::new(path) |
| 1065 | 1139 | .file_stem() |
| 1066 | 1140 | .map(|stem| stem.to_string_lossy().into_owned()) |
| ... | ... | @@ -1079,6 +1153,12 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { |
| 1079 | 1153 | path: section.path.clone(), |
| 1080 | 1154 | color: *color, |
| 1081 | 1155 | }), |
| 1156 | // Its name and colour are inside the encryption, but its file is named for it. | |
| 1157 | SectionState::Locked => Some(Tab { | |
| 1158 | name: section_name(&section.path, &None), | |
| 1159 | path: section.path.clone(), | |
| 1160 | color: None, | |
| 1161 | }), | |
| 1082 | 1162 | _ => None, |
| 1083 | 1163 | }) |
| 1084 | 1164 | .collect() |
| ... | ... | @@ -1094,6 +1174,8 @@ pub enum Located { |
| 1094 | 1174 | }, |
| 1095 | 1175 | /// A section file outside any notebook. |
| 1096 | 1176 | Section(PathBuf), |
| 1177 | /// A OneNote package, which unpacks into a notebook. | |
| 1178 | Package(PathBuf), | |
| 1097 | 1179 | Nothing, |
| 1098 | 1180 | } |
| 1099 | 1181 | |
| ... | ... | @@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool { |
| 1111 | 1193 | |
| 1112 | 1194 | /// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a |
| 1113 | 1195 | /// section opens in the notebook the folders above it with tables of contents make up, |
| 1114 | /// section groups included, or alone. | |
| 1196 | /// section groups included, or alone; a package asks to be unpacked. | |
| 1115 | 1197 | pub fn locate(path: &Path) -> Located { |
| 1116 | 1198 | let extension = path |
| 1117 | 1199 | .extension() |
| ... | ... | @@ -1121,6 +1203,7 @@ pub fn locate(path: &Path) -> Located { |
| 1121 | 1203 | root: path.to_owned(), |
| 1122 | 1204 | section: None, |
| 1123 | 1205 | }, |
| 1206 | Some("onepkg") => Located::Package(path.to_owned()), | |
| 1124 | 1207 | Some("onetoc2") => match path.parent() { |
| 1125 | 1208 | Some(root) => Located::Notebook { |
| 1126 | 1209 | root: root.to_owned(), |
| ... | ... | @@ -1327,9 +1410,6 @@ mod tests { |
| 1327 | 1410 | #[test] |
| 1328 | 1411 | fn share_relative_sections_show_under_their_mount() { |
| 1329 | 1412 | let library = |root: &str| Library { |
| 1330 | location: "/Volumes/agent/lab".into(), | |
| 1331 | name: "lab".into(), | |
| 1332 | notebook: Ok(None), | |
| 1333 | 1413 | server: Some(Arc::new(Server { |
| 1334 | 1414 | mount: Mount { |
| 1335 | 1415 | server: "nas".into(), |
| ... | ... | @@ -1344,13 +1424,7 @@ mod tests { |
| 1344 | 1424 | domain: String::new(), |
| 1345 | 1425 | }, |
| 1346 | 1426 | })), |
| 1347 | notice: None, | |
| 1348 | cache: PathBuf::new(), | |
| 1349 | background: None, | |
| 1350 | watch: None, | |
| 1351 | tag_art: Default::default(), | |
| 1352 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | |
| 1353 | themes: Default::default(), | |
| 1427 | ..Library::new("/Volumes/agent/lab", "lab".into(), Path::new("")) | |
| 1354 | 1428 | }; |
| 1355 | 1429 | let shown = |root, file| library(root).local(Path::new(file)); |
| 1356 | 1430 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
| ... | ... | @@ -1447,6 +1521,10 @@ mod tests { |
| 1447 | 1521 | locate(&root.join("Loose.one")), |
| 1448 | 1522 | Located::Section(root.join("Loose.one")) |
| 1449 | 1523 | ); |
| 1524 | assert_eq!( | |
| 1525 | locate(&root.join("Shared.onepkg")), | |
| 1526 | Located::Package(root.join("Shared.onepkg")) | |
| 1527 | ); | |
| 1450 | 1528 | assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); |
| 1451 | 1529 | std::fs::remove_dir_all(&root).unwrap(); |
| 1452 | 1530 | } |
crates/snowbound/src/link.rs+1-16| ... | ... | @@ -226,22 +226,7 @@ impl State { |
| 226 | 226 | } |
| 227 | 227 | } |
| 228 | 228 | ui.close(); |
| 229 | ui.open( | |
| 230 | "buttons", | |
| 231 | Spec { | |
| 232 | size: [fill(), children()], | |
| 233 | pad: [0.0, 8.0], | |
| 234 | gap: 8.0, | |
| 235 | ..Spec::default() | |
| 236 | }, | |
| 237 | ); | |
| 238 | ui.leaf( | |
| 239 | "space", | |
| 240 | Spec { | |
| 241 | size: [fill(), px(1.0)], | |
| 242 | ..Spec::default() | |
| 243 | }, | |
| 244 | ); | |
| 229 | crate::buttons(ui); | |
| 245 | 230 | let ok = ui::button(ui, "ok", "OK").clicked || entered; |
| 246 | 231 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 247 | 232 | ui.close(); |
crates/snowbound/src/linux.rs+39-43| ... | ... | @@ -72,12 +72,12 @@ pub fn corner_radius(window: &Window) -> f32 { |
| 72 | 72 | (units * scale).round() / scale |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | /// Whether the window erases its own bottom corners to transparent pixels: under winit's | |
| 76 | /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 | |
| 77 | /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. | |
| 78 | 75 | /// Winit's theme already holds the window to the app's appearance. |
| 79 | 76 | pub fn follow_appearance(_: &Window, _: Theme) {} |
| 80 | 77 | |
| 78 | /// Whether the window erases its own bottom corners to transparent pixels: under winit's | |
| 79 | /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 | |
| 80 | /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. | |
| 81 | 81 | pub fn cuts_corners() -> bool { |
| 82 | 82 | sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) |
| 83 | 83 | } |
| ... | ... | @@ -617,6 +617,11 @@ pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option<ResizeDire |
| 617 | 617 | (!window.is_maximized() && window.is_resizable()).then_some(direction) |
| 618 | 618 | } |
| 619 | 619 | |
| 620 | /// Keeps the window's client area at least `size` points. | |
| 621 | pub fn set_min_size(window: &Window, size: [f32; 2]) { | |
| 622 | window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size[0], size[1]))); | |
| 623 | } | |
| 624 | ||
| 620 | 625 | pub fn zoom(window: &Window) { |
| 621 | 626 | window.set_maximized(!window.is_maximized()); |
| 622 | 627 | } |
| ... | ... | @@ -773,18 +778,19 @@ pub fn show_file(file: &std::path::Path) { |
| 773 | 778 | |
| 774 | 779 | /// The `file://` URI of absolute `path`, its bytes outside RFC 3986's unreserved set and `/` |
| 775 | 780 | /// percent-encoded. |
| 776 | fn file_uri(path: &std::path::Path) -> String { | |
| 781 | pub(crate) fn file_uri(path: &std::path::Path) -> String { | |
| 777 | 782 | use std::os::unix::ffi::OsStrExt; |
| 778 | let mut uri = "file://".to_owned(); | |
| 779 | for &byte in path.as_os_str().as_bytes() { | |
| 780 | match byte { | |
| 781 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { | |
| 782 | uri.push(byte as char) | |
| 783 | } | |
| 784 | _ => uri.push_str(&format!("%{byte:02X}")), | |
| 785 | } | |
| 786 | } | |
| 787 | uri | |
| 783 | format!( | |
| 784 | "file://{}", | |
| 785 | crate::library::encode(path.as_os_str().as_bytes()) | |
| 786 | ) | |
| 787 | } | |
| 788 | ||
| 789 | /// The function `name` in `library`, of C signature `F`. | |
| 790 | pub(crate) unsafe fn symbol<F: Copy>(library: *mut libc::c_void, name: &CStr) -> Option<F> { | |
| 791 | let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; | |
| 792 | (!symbol.is_null()) | |
| 793 | .then(|| unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) }) | |
| 788 | 794 | } |
| 789 | 795 | |
| 790 | 796 | pub fn cache_dir() -> Option<PathBuf> { |
| ... | ... | @@ -1124,6 +1130,17 @@ fn dialog<const Z: usize, const K: usize>( |
| 1124 | 1130 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) |
| 1125 | 1131 | } |
| 1126 | 1132 | |
| 1133 | /// The path a file dialog answered; none when cancelled or when no tool could ask. | |
| 1134 | fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> { | |
| 1135 | asked | |
| 1136 | .unwrap_or_else(|error| { | |
| 1137 | eprintln!("{error}"); | |
| 1138 | None | |
| 1139 | }) | |
| 1140 | .filter(|path| !path.is_empty()) | |
| 1141 | .map(PathBuf::from) | |
| 1142 | } | |
| 1143 | ||
| 1127 | 1144 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1128 | 1145 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1129 | 1146 | let patterns = match types { |
| ... | ... | @@ -1134,21 +1151,14 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1134 | 1151 | .collect::<Vec<_>>() |
| 1135 | 1152 | .join(" "), |
| 1136 | 1153 | }; |
| 1137 | let asked = dialog( | |
| 1154 | picked(dialog( | |
| 1138 | 1155 | [ |
| 1139 | 1156 | "--file-selection", |
| 1140 | 1157 | &format!("--title={title}"), |
| 1141 | 1158 | &format!("--file-filter={patterns}"), |
| 1142 | 1159 | ], |
| 1143 | 1160 | ["--getopenfilename", ".", &patterns, "--title", title], |
| 1144 | ); | |
| 1145 | asked | |
| 1146 | .unwrap_or_else(|error| { | |
| 1147 | eprintln!("{error}"); | |
| 1148 | None | |
| 1149 | }) | |
| 1150 | .filter(|path| !path.is_empty()) | |
| 1151 | .map(PathBuf::from) | |
| 1161 | )) | |
| 1152 | 1162 | } |
| 1153 | 1163 | |
| 1154 | 1164 | /// No icon theme lookup; the page draws a blank page for the file. |
| ... | ... | @@ -1210,27 +1220,20 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool |
| 1210 | 1220 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when |
| 1211 | 1221 | /// cancelled or when no tool can ask. |
| 1212 | 1222 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1213 | let asked = dialog( | |
| 1223 | picked(dialog( | |
| 1214 | 1224 | [ |
| 1215 | 1225 | "--file-selection", |
| 1216 | 1226 | &format!("--title={title}"), |
| 1217 | "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one", | |
| 1227 | "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg", | |
| 1218 | 1228 | ], |
| 1219 | 1229 | [ |
| 1220 | 1230 | "--getopenfilename", |
| 1221 | 1231 | ".", |
| 1222 | "*.onetoc2 *.one", | |
| 1232 | "*.onetoc2 *.one *.onepkg", | |
| 1223 | 1233 | "--title", |
| 1224 | 1234 | title, |
| 1225 | 1235 | ], |
| 1226 | ); | |
| 1227 | asked | |
| 1228 | .unwrap_or_else(|error| { | |
| 1229 | eprintln!("{error}"); | |
| 1230 | None | |
| 1231 | }) | |
| 1232 | .filter(|path| !path.is_empty()) | |
| 1233 | .map(PathBuf::from) | |
| 1236 | )) | |
| 1234 | 1237 | } |
| 1235 | 1238 | |
| 1236 | 1239 | /// Asks where to put something named `name` by default; the dialog names its own button. |
| ... | ... | @@ -1240,7 +1243,7 @@ pub fn pick_new( |
| 1240 | 1243 | _action: &str, |
| 1241 | 1244 | _folder: Option<&std::path::Path>, |
| 1242 | 1245 | ) -> Option<PathBuf> { |
| 1243 | let asked = dialog( | |
| 1246 | picked(dialog( | |
| 1244 | 1247 | [ |
| 1245 | 1248 | "--file-selection", |
| 1246 | 1249 | "--save", |
| ... | ... | @@ -1248,14 +1251,7 @@ pub fn pick_new( |
| 1248 | 1251 | &format!("--filename={name}"), |
| 1249 | 1252 | ], |
| 1250 | 1253 | ["--getsavefilename", name, "--title", title], |
| 1251 | ); | |
| 1252 | asked | |
| 1253 | .unwrap_or_else(|error| { | |
| 1254 | eprintln!("{error}"); | |
| 1255 | None | |
| 1256 | }) | |
| 1257 | .filter(|path| !path.is_empty()) | |
| 1258 | .map(PathBuf::from) | |
| 1254 | )) | |
| 1259 | 1255 | } |
| 1260 | 1256 | |
| 1261 | 1257 | /// Tells the user something they asked for could not be done: `message`, then what to do. |
crates/snowbound/src/macos.rs+34-44| ... | ... | @@ -37,7 +37,7 @@ pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { |
| 37 | 37 | |
| 38 | 38 | pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; |
| 39 | 39 | |
| 40 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | |
| 40 | static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | |
| 41 | 41 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 42 | 42 | static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 43 | 43 | static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| ... | ... | @@ -71,23 +71,25 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran |
| 71 | 71 | } else { |
| 72 | 72 | msg_send_id![text, copy] |
| 73 | 73 | }; |
| 74 | if let Some(proxy) = QUIT.get() { | |
| 74 | if let Some(proxy) = PROXY.get() { | |
| 75 | 75 | let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string())); |
| 76 | 76 | } |
| 77 | 77 | } |
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | fn ns_window(window: &Window) -> Retained<AnyObject> { | |
| 81 | /// The view winit draws the window's content in. | |
| 82 | fn ns_view(window: &Window) -> Retained<AnyObject> { | |
| 82 | 83 | let RawWindowHandle::AppKit(handle) = |
| 83 | 84 | window.window_handle().expect("Live AppKit window").as_raw() |
| 84 | 85 | else { |
| 85 | 86 | unreachable!() |
| 86 | 87 | }; |
| 87 | unsafe { | |
| 88 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 89 | msg_send_id![view, window] | |
| 90 | } | |
| 88 | unsafe { Retained::retain(handle.ns_view.as_ptr().cast()) }.expect("A live window has a view") | |
| 89 | } | |
| 90 | ||
| 91 | pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> { | |
| 92 | unsafe { msg_send_id![&ns_view(window), window] } | |
| 91 | 93 | } |
| 92 | 94 | |
| 93 | 95 | /// Room the traffic lights take at the title bar's leading edge. |
| ... | ... | @@ -107,11 +109,11 @@ pub fn corner_radius(_: &Window) -> f32 { |
| 107 | 109 | } |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | /// AppKit clips the window's corners itself, except where the OpenGL surface covers | |
| 111 | /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. | |
| 112 | 112 | /// Winit's theme already holds the window to the app's appearance. |
| 113 | 113 | pub fn follow_appearance(_: &Window, _: winit::window::Theme) {} |
| 114 | 114 | |
| 115 | /// AppKit clips the window's corners itself, except where the OpenGL surface covers | |
| 116 | /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. | |
| 115 | 117 | pub fn cuts_corners() -> bool { |
| 116 | 118 | crate::aqua::before_lion() |
| 117 | 119 | } |
| ... | ... | @@ -332,13 +334,8 @@ pub fn install_backdrop(window: &Window) -> bool { |
| 332 | 334 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { |
| 333 | 335 | return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); |
| 334 | 336 | }; |
| 335 | let RawWindowHandle::AppKit(handle) = | |
| 336 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 337 | else { | |
| 338 | unreachable!() | |
| 339 | }; | |
| 337 | let view = &*ns_view(window); | |
| 340 | 338 | unsafe { |
| 341 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 342 | 339 | let class = BACKDROP_CLASS.get_or_init(|| { |
| 343 | 340 | let mut class = |
| 344 | 341 | ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); |
| ... | ... | @@ -881,6 +878,11 @@ pub fn appearance(window: &Window) -> winit::window::Theme { |
| 881 | 878 | window.theme().unwrap_or(winit::window::Theme::Dark) |
| 882 | 879 | } |
| 883 | 880 | |
| 881 | /// Keeps the window's client area at least `size` points. | |
| 882 | pub fn set_min_size(window: &Window, size: [f32; 2]) { | |
| 883 | window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size[0], size[1]))); | |
| 884 | } | |
| 885 | ||
| 884 | 886 | /// Zooms the window once the current event is handled: AppKit's zoom animation runs its |
| 885 | 887 | /// own loop, and started from inside winit's handler it would hold every resize until the |
| 886 | 888 | /// end, stretching the last frame instead of drawing each step. |
| ... | ... | @@ -899,13 +901,8 @@ pub fn zoom(window: &Window) { |
| 899 | 901 | /// Install before AccessKit subclasses the same view, preserving its restoration chain. |
| 900 | 902 | pub fn install_text_input(window: &Window) { |
| 901 | 903 | MainThreadMarker::new().expect("Text input belongs to the main thread"); |
| 902 | let RawWindowHandle::AppKit(handle) = | |
| 903 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 904 | else { | |
| 905 | unreachable!() | |
| 906 | }; | |
| 904 | let view = &*ns_view(window); | |
| 907 | 905 | unsafe { |
| 908 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 909 | 906 | let class = INPUT_CLASS.get_or_init(|| { |
| 910 | 907 | let mut class = ClassBuilder::new("SnowboundTextInputView", view.class()) |
| 911 | 908 | .expect("Unique text input class"); |
| ... | ... | @@ -965,9 +962,7 @@ pub fn edit_date( |
| 965 | 962 | } |
| 966 | 963 | picker.setCalendar(Some(&calendar)); |
| 967 | 964 | picker.setTimeZone(Some(&calendar.timeZone())); |
| 968 | picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( | |
| 969 | (timestamp / 10_000_000) as f64 - 11_644_473_600.0, | |
| 970 | )); | |
| 965 | picker.setDateValue(&ns_date(timestamp)); | |
| 971 | 966 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( |
| 972 | 967 | -11_644_473_600.0, |
| 973 | 968 | ))); |
| ... | ... | @@ -996,9 +991,7 @@ fn merge_date( |
| 996 | 991 | calendar: &NSCalendar, |
| 997 | 992 | ) -> Result<(u64, [String; 2]), &'static str> { |
| 998 | 993 | unsafe { |
| 999 | let original = NSDate::dateWithTimeIntervalSince1970( | |
| 1000 | (timestamp / 10_000_000) as f64 - 11_644_473_600.0, | |
| 1001 | ); | |
| 994 | let original = ns_date(timestamp); | |
| 1002 | 995 | let units = NSCalendarUnit::Era |
| 1003 | 996 | | NSCalendarUnit::Year |
| 1004 | 997 | | NSCalendarUnit::Month |
| ... | ... | @@ -1053,11 +1046,14 @@ fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] { |
| 1053 | 1046 | |
| 1054 | 1047 | /// FILETIME as a new page's title shows it: the long date and the short time. |
| 1055 | 1048 | pub fn date_text(filetime: u64) -> [String; 2] { |
| 1049 | let calendar = unsafe { NSCalendar::currentCalendar() }; | |
| 1050 | labels(&ns_date(filetime), &calendar) | |
| 1051 | } | |
| 1052 | ||
| 1053 | /// FILETIME as an `NSDate`, to the second. | |
| 1054 | fn ns_date(filetime: u64) -> Retained<NSDate> { | |
| 1056 | 1055 | unsafe { |
| 1057 | let date = NSDate::dateWithTimeIntervalSince1970( | |
| 1058 | (filetime / 10_000_000) as f64 - 11_644_473_600.0, | |
| 1059 | ); | |
| 1060 | labels(&date, &NSCalendar::currentCalendar()) | |
| 1056 | NSDate::dateWithTimeIntervalSince1970((filetime / 10_000_000) as f64 - 11_644_473_600.0) | |
| 1061 | 1057 | } |
| 1062 | 1058 | } |
| 1063 | 1059 | |
| ... | ... | @@ -1069,9 +1065,7 @@ pub fn user_name() -> String { |
| 1069 | 1065 | /// FILETIME as the system's short date, as OneNote labels a conflict page. |
| 1070 | 1066 | pub fn short_date(filetime: u64) -> String { |
| 1071 | 1067 | unsafe { |
| 1072 | let date = NSDate::dateWithTimeIntervalSince1970( | |
| 1073 | (filetime / 10_000_000) as f64 - 11_644_473_600.0, | |
| 1074 | ); | |
| 1068 | let date = ns_date(filetime); | |
| 1075 | 1069 | let formatter = NSDateFormatter::new(); |
| 1076 | 1070 | formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); |
| 1077 | 1071 | formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); |
| ... | ... | @@ -1231,13 +1225,13 @@ declare_class!( |
| 1231 | 1225 | |
| 1232 | 1226 | #[method(terminate:)] |
| 1233 | 1227 | fn terminate(&self, _sender: Option<&AnyObject>) { |
| 1234 | if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } | |
| 1228 | if let Some(proxy) = PROXY.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } | |
| 1235 | 1229 | } |
| 1236 | 1230 | |
| 1237 | 1231 | #[method(choose:)] |
| 1238 | 1232 | fn choose(&self, sender: &NSMenuItem) { |
| 1239 | 1233 | let tag = usize::try_from(unsafe { sender.tag() }).ok(); |
| 1240 | if let (Some(proxy), Some(choice)) = (QUIT.get(), tag.and_then(|tag| commands::choices().nth(tag))) { | |
| 1234 | if let (Some(proxy), Some(choice)) = (PROXY.get(), tag.and_then(|tag| commands::choices().nth(tag))) { | |
| 1241 | 1235 | let _ = proxy.send_event(crate::UserEvent::Choose(choice)); |
| 1242 | 1236 | } |
| 1243 | 1237 | } |
| ... | ... | @@ -1302,7 +1296,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 1302 | 1296 | .with_activate_ignoring_other_apps(false); |
| 1303 | 1297 | } |
| 1304 | 1298 | let event_loop = builder.build()?; |
| 1305 | QUIT.set(event_loop.create_proxy()) | |
| 1299 | PROXY | |
| 1300 | .set(event_loop.create_proxy()) | |
| 1306 | 1301 | .expect("Only one application event loop is created"); |
| 1307 | 1302 | // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for |
| 1308 | 1303 | // the Finder's and `open`'s documents, before the launch finishes on a cold launch. |
| ... | ... | @@ -1328,7 +1323,7 @@ unsafe extern "C" fn open_files( |
| 1328 | 1323 | app: &NSApplication, |
| 1329 | 1324 | files: &objc2_foundation::NSArray<NSString>, |
| 1330 | 1325 | ) { |
| 1331 | if let Some(proxy) = QUIT.get() { | |
| 1326 | if let Some(proxy) = PROXY.get() { | |
| 1332 | 1327 | let paths = files.iter().map(|file| file.to_string().into()).collect(); |
| 1333 | 1328 | let _ = proxy.send_event(crate::UserEvent::Open(paths)); |
| 1334 | 1329 | } |
| ... | ... | @@ -1392,14 +1387,9 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool |
| 1392 | 1387 | /// End AppKit preedit after a canvas action commits or cancels the composition. |
| 1393 | 1388 | pub fn clear_marked_text(window: &Window) { |
| 1394 | 1389 | MainThreadMarker::new().expect("Text input belongs to the main thread"); |
| 1395 | let RawWindowHandle::AppKit(handle) = | |
| 1396 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 1397 | else { | |
| 1398 | unreachable!() | |
| 1399 | }; | |
| 1400 | 1390 | // Winit owns this view and implements the NSTextInputClient selectors. |
| 1391 | let view = &*ns_view(window); | |
| 1401 | 1392 | unsafe { |
| 1402 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 1403 | 1393 | let marked: bool = msg_send![view, hasMarkedText]; |
| 1404 | 1394 | if marked { |
| 1405 | 1395 | let _: () = msg_send![view, unmarkText]; |
crates/snowbound/src/main.rs+326-231| ... | ... | @@ -40,13 +40,16 @@ mod paste; |
| 40 | 40 | mod platform; |
| 41 | 41 | mod prefetch; |
| 42 | 42 | mod print; |
| 43 | mod properties; | |
| 44 | 43 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] |
| 45 | 44 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] |
| 46 | 45 | #[cfg_attr(windows, path = "print_windows.rs")] |
| 47 | 46 | mod printer; |
| 47 | mod properties; | |
| 48 | mod protection; | |
| 48 | 49 | mod recording; |
| 50 | mod recycle; | |
| 49 | 51 | mod rename; |
| 52 | mod save_as; | |
| 50 | 53 | mod screenshot; |
| 51 | 54 | mod search; |
| 52 | 55 | mod server; |
| ... | ... | @@ -64,6 +67,8 @@ mod tags; |
| 64 | 67 | mod templates; |
| 65 | 68 | mod themes; |
| 66 | 69 | mod undo; |
| 70 | mod unpack; | |
| 71 | mod unread; | |
| 67 | 72 | mod update; |
| 68 | 73 | mod video; |
| 69 | 74 | mod watch; |
| ... | ... | @@ -106,6 +111,12 @@ use winit::{ |
| 106 | 111 | const TITLE: f32 = 38.0; |
| 107 | 112 | /// Space between toolbar groups. |
| 108 | 113 | const GAP: f32 = 6.0; |
| 114 | /// A tool face's room beside its buttons. | |
| 115 | const FACE_PAD: f32 = 5.0; | |
| 116 | /// How far a tool face stands above and below its buttons. | |
| 117 | const FACE_RISE: f32 = 3.0; | |
| 118 | /// Space between groups on faces of their own. | |
| 119 | const FACE_GAP: f32 = 2.0; | |
| 109 | 120 | const TAB_ROW: f32 = 28.0; |
| 110 | 121 | /// Width of the section colour around the page, and the rows' margin at the window's sides. |
| 111 | 122 | const FRAME: f32 = 6.0; |
| ... | ... | @@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0; |
| 115 | 126 | const MIN_SIZE: [f32; 2] = [600.0, 400.0]; |
| 116 | 127 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. |
| 117 | 128 | const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; |
| 118 | /// Font sizes the size box offers, OneNote's list in points. | |
| 119 | 129 | /// Fonts the font box offers first, OneNote's default leading. |
| 120 | 130 | const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; |
| 121 | 131 | /// Picks the font box and the list galleries offer as recent, at most, as OneNote's |
| ... | ... | @@ -579,6 +589,8 @@ enum Command { |
| 579 | 589 | }, |
| 580 | 590 | /// Deletes pages of the open section to the notebook's recycle bin. |
| 581 | 591 | DeletePages(Vec<ExGuid>), |
| 592 | /// Restores or deletes for good pages of the recycle bin's open section. | |
| 593 | Recycle(recycle::Request), | |
| 582 | 594 | /// Moves or indents pages of the open section. |
| 583 | 595 | Pages(Vec<onestore::PageEdit>), |
| 584 | 596 | /// Moves a page of the open section to the end of another section of its folder. |
| ... | ... | @@ -672,7 +684,7 @@ struct State { |
| 672 | 684 | cache: PathBuf, |
| 673 | 685 | /// Whether the notebook sidebar is expanded. |
| 674 | 686 | sidebar: bool, |
| 675 | /// Notebooks and section groups whose rows the sidebar folds, by `sidebar::fold_key`. | |
| 687 | /// Notebooks and section groups whose rows the sidebar folds, by `Library::key`. | |
| 676 | 688 | folded: HashSet<String>, |
| 677 | 689 | /// The open context menu: what it was opened on, and where. |
| 678 | 690 | menu: Option<(menus::Target, [f32; 2])>, |
| ... | ... | @@ -693,6 +705,12 @@ struct State { |
| 693 | 705 | /// The Link dialog's fields while it is open. |
| 694 | 706 | link: Option<link::LinkDialog>, |
| 695 | 707 | properties: Option<properties::Properties>, |
| 708 | /// Save As while it is open. | |
| 709 | save_as: Option<save_as::Dialog>, | |
| 710 | /// Unpack Notebook while it is open. | |
| 711 | unpacking: Option<unpack::Dialog>, | |
| 712 | /// What has been read in each notebook, on this computer. | |
| 713 | reads: unread::Reads, | |
| 696 | 714 | /// Open Notebook from Server while it is open. |
| 697 | 715 | server: Option<server::Connect>, |
| 698 | 716 | /// New iCloud Notebook while it is open. |
| ... | ... | @@ -728,6 +746,12 @@ struct State { |
| 728 | 746 | /// The notebook shown while it has no section open, as one whose last section was |
| 729 | 747 | /// deleted. |
| 730 | 748 | sectionless: Option<Arc<Library>>, |
| 749 | /// A password-protected section shown locked, in place of an open one. | |
| 750 | locked: Option<protection::Locked>, | |
| 751 | /// The password dialog while it is open. | |
| 752 | password: Option<protection::Asking>, | |
| 753 | /// Options' Passwords. | |
| 754 | passwords: settings::Passwords, | |
| 731 | 755 | /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. |
| 732 | 756 | drag: Option<menus::Drag>, |
| 733 | 757 | /// Pages whose template strip was dismissed this run. |
| ... | ... | @@ -879,6 +903,7 @@ impl State { |
| 879 | 903 | let mut notebooks = Vec::new(); |
| 880 | 904 | let mut session = None; |
| 881 | 905 | let mut sectionless = None; |
| 906 | let mut locked = None; | |
| 882 | 907 | let (editor, scene) = match input { |
| 883 | 908 | Input::Notes { |
| 884 | 909 | document, |
| ... | ... | @@ -933,7 +958,7 @@ impl State { |
| 933 | 958 | .chain(&notebooks) |
| 934 | 959 | .find_map(|library| Some((library, library.first_section()?))); |
| 935 | 960 | match shown { |
| 936 | Some((library, path)) => { | |
| 961 | Some((library, path)) if !library.locked(&path) => { | |
| 937 | 962 | let section = library.open(&path, notify(proxy.clone()))?; |
| 938 | 963 | let (opened, page) = |
| 939 | 964 | read_session(section, Arc::clone(library), path, None)?; |
| ... | ... | @@ -941,11 +966,17 @@ impl State { |
| 941 | 966 | session = Some(opened); |
| 942 | 967 | (editor, Some((scene, [0.0; 2]))) |
| 943 | 968 | } |
| 944 | None => { | |
| 969 | shown => { | |
| 970 | // A first section still locked shows its locked page. | |
| 971 | locked = shown.map(|(library, path)| protection::Locked { | |
| 972 | library: Arc::clone(library), | |
| 973 | path, | |
| 974 | }); | |
| 945 | 975 | sectionless = notebooks |
| 946 | 976 | .iter() |
| 947 | 977 | .find(|library| Some(&library.location) == current.as_ref()) |
| 948 | 978 | .or(notebooks.first()) |
| 979 | .filter(|_| locked.is_none()) | |
| 949 | 980 | .cloned(); |
| 950 | 981 | let blank = TextDocument::new(vec![Paragraph::new( |
| 951 | 982 | String::new(), |
| ... | ... | @@ -1014,6 +1045,7 @@ impl State { |
| 1014 | 1045 | .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); |
| 1015 | 1046 | let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); |
| 1016 | 1047 | let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); |
| 1048 | let reads = unread::Reads::load(&cache); | |
| 1017 | 1049 | let mut state = Self { |
| 1018 | 1050 | author: stored.user_name.unwrap_or_else(platform::user_name), |
| 1019 | 1051 | window, |
| ... | ... | @@ -1045,6 +1077,9 @@ impl State { |
| 1045 | 1077 | after_open: None, |
| 1046 | 1078 | link: None, |
| 1047 | 1079 | properties: None, |
| 1080 | save_as: None, | |
| 1081 | unpacking: None, | |
| 1082 | reads, | |
| 1048 | 1083 | server: None, |
| 1049 | 1084 | new_icloud: None, |
| 1050 | 1085 | icloud_reading: HashSet::new(), |
| ... | ... | @@ -1062,6 +1097,9 @@ impl State { |
| 1062 | 1097 | titlebar, |
| 1063 | 1098 | renaming: None, |
| 1064 | 1099 | sectionless, |
| 1100 | locked, | |
| 1101 | password: None, | |
| 1102 | passwords: stored.passwords, | |
| 1065 | 1103 | drag: None, |
| 1066 | 1104 | templates: templates::View::Strip, |
| 1067 | 1105 | media: Default::default(), |
| ... | ... | @@ -1142,6 +1180,7 @@ impl State { |
| 1142 | 1180 | eprintln!("{error}"); |
| 1143 | 1181 | } |
| 1144 | 1182 | lap("open", start); |
| 1183 | self.follow_reading(); | |
| 1145 | 1184 | let size = self.window.inner_size(); |
| 1146 | 1185 | let scale = self.scale(); |
| 1147 | 1186 | if scale != self.ui.scale() { |
| ... | ... | @@ -1170,6 +1209,7 @@ impl State { |
| 1170 | 1209 | self.ime_allowed = ime; |
| 1171 | 1210 | self.window.set_ime_allowed(ime); |
| 1172 | 1211 | } |
| 1212 | self.lock_idle()?; | |
| 1173 | 1213 | self.draw()?; |
| 1174 | 1214 | lap("drawn", start); |
| 1175 | 1215 | self.sync_index(false, Vec::new()); |
| ... | ... | @@ -1211,6 +1251,9 @@ impl State { |
| 1211 | 1251 | self.new_icloud_dialog(); |
| 1212 | 1252 | self.link_dialog()?; |
| 1213 | 1253 | self.properties_dialog(); |
| 1254 | self.save_as_dialog(); | |
| 1255 | self.unpack_dialog(); | |
| 1256 | self.password_dialog()?; | |
| 1214 | 1257 | self.customize_tags(); |
| 1215 | 1258 | self.palette(); |
| 1216 | 1259 | self.sync_popup()?; |
| ... | ... | @@ -1314,7 +1357,10 @@ impl State { |
| 1314 | 1357 | let mut theme = self.ui.theme.clone(); |
| 1315 | 1358 | theme.paper = self.paper().color; |
| 1316 | 1359 | platform::update_menu(|| self.statuses()); |
| 1317 | let welcome = self.session.is_none() && !self.temporary && self.sectionless.is_none(); | |
| 1360 | let welcome = self.session.is_none() | |
| 1361 | && !self.temporary | |
| 1362 | && self.sectionless.is_none() | |
| 1363 | && self.locked.is_none(); | |
| 1318 | 1364 | self.toolbar(&theme, !welcome)?; |
| 1319 | 1365 | if welcome { |
| 1320 | 1366 | self.welcome(&theme); |
| ... | ... | @@ -1380,7 +1426,9 @@ impl State { |
| 1380 | 1426 | ..Spec::default() |
| 1381 | 1427 | }, |
| 1382 | 1428 | ); |
| 1429 | self.recycle_heading(&theme); | |
| 1383 | 1430 | let row = sections(); |
| 1431 | let unread = self.unread_keys(); | |
| 1384 | 1432 | let (clicked, open_tab) = match &self.session { |
| 1385 | 1433 | Some(session) => { |
| 1386 | 1434 | // A tab being renamed takes the name typed, which its field covers. |
| ... | ... | @@ -1394,7 +1442,8 @@ impl State { |
| 1394 | 1442 | } |
| 1395 | 1443 | _ => tab.name.as_str(), |
| 1396 | 1444 | }; |
| 1397 | (name, section_color(tab.color)) | |
| 1445 | let unread = unread.contains(&session.library.key(&tab.path)); | |
| 1446 | (name, section_color(tab.color), unread) | |
| 1398 | 1447 | }) |
| 1399 | 1448 | .collect(); |
| 1400 | 1449 | let lit = self.page_drop(row); |
| ... | ... | @@ -1453,8 +1502,9 @@ impl State { |
| 1453 | 1502 | self.tab_rename_field(&theme, row, tab_row); |
| 1454 | 1503 | (clicked, open_tab) |
| 1455 | 1504 | } |
| 1505 | None if self.locked.is_some() => self.locked_tabs(row, &section, theme.strip), | |
| 1456 | 1506 | None => { |
| 1457 | let tabs = [("Temporary page", section_color(None))]; | |
| 1507 | let tabs = [("Temporary page", section_color(None), false)]; | |
| 1458 | 1508 | let shown = if self.temporary { &tabs[..] } else { &[] }; |
| 1459 | 1509 | let open_tab = ui::shell::section_tabs( |
| 1460 | 1510 | &mut self.ui, |
| ... | ... | @@ -1537,6 +1587,22 @@ impl State { |
| 1537 | 1587 | self.commands |
| 1538 | 1588 | .extend(conflict_bar(&mut self.ui, bar, &sections, steps)); |
| 1539 | 1589 | } |
| 1590 | if self.in_recycle_bin() { | |
| 1591 | recycle::bar(&mut self.ui); | |
| 1592 | } | |
| 1593 | if self.session.is_none() && self.locked.is_some() { | |
| 1594 | self.locked_page(&theme); | |
| 1595 | self.ui.close(); | |
| 1596 | self.ui.close(); | |
| 1597 | self.ui.close(); | |
| 1598 | if self.navigation_bar_right { | |
| 1599 | self.sidebar(&theme, sidebar); | |
| 1600 | } | |
| 1601 | self.sidebar_button(&theme, height); | |
| 1602 | self.ui.close(); | |
| 1603 | self.context_menu(); | |
| 1604 | return Ok((section, open_tab, None)); | |
| 1605 | } | |
| 1540 | 1606 | if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) { |
| 1541 | 1607 | self.no_sections(&theme, library); |
| 1542 | 1608 | self.ui.close(); |
| ... | ... | @@ -1816,7 +1882,6 @@ impl State { |
| 1816 | 1882 | Flags::default() |
| 1817 | 1883 | }, |
| 1818 | 1884 | size: [fill(), px(TITLE)], |
| 1819 | fill: Some(theme.strip), | |
| 1820 | 1885 | pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], |
| 1821 | 1886 | gap: GAP, |
| 1822 | 1887 | role: Some(accesskit::Role::Toolbar), |
| ... | ... | @@ -1827,6 +1892,20 @@ impl State { |
| 1827 | 1892 | } else { |
| 1828 | 1893 | self.ui.open("toolbar", spec) |
| 1829 | 1894 | }; |
| 1895 | // The strip stops short of the window's controls, where Windows 7 draws its caption | |
| 1896 | // buttons beneath the window's pixels. | |
| 1897 | let edge = (TITLE - ui::shell::TOOL) / 2.0; | |
| 1898 | self.ui.open( | |
| 1899 | "bar", | |
| 1900 | Spec { | |
| 1901 | size: [fill(), px(TITLE)], | |
| 1902 | fill: Some(theme.strip), | |
| 1903 | pad: [0.0, edge], | |
| 1904 | offset: [0.0, -edge], | |
| 1905 | gap: GAP, | |
| 1906 | ..Spec::default() | |
| 1907 | }, | |
| 1908 | ); | |
| 1830 | 1909 | let lead = if title { platform::LEADING } else { FRAME }; |
| 1831 | 1910 | self.ui.leaf( |
| 1832 | 1911 | "lead", |
| ... | ... | @@ -1838,16 +1917,21 @@ impl State { |
| 1838 | 1917 | if tools { |
| 1839 | 1918 | // The groups fold in the room the window's controls leave, and whatever still |
| 1840 | 1919 | // overflows is cut there, so the controls always show. |
| 1841 | let edge = (TITLE - ui::shell::TOOL) / 2.0; | |
| 1920 | let panel = theme.tool[3] > 0.0 && theme.tool_panel; | |
| 1921 | let face = tool_face(theme, panel, FACE_RISE - edge); | |
| 1842 | 1922 | self.ui.open( |
| 1843 | 1923 | "tools", |
| 1844 | 1924 | Spec { |
| 1845 | 1925 | flags: Flags::CLIP, |
| 1846 | 1926 | size: [fill(), px(TITLE)], |
| 1847 | pad: [0.0, edge], | |
| 1927 | pad: [face.pad[0], edge], | |
| 1848 | 1928 | offset: [0.0, -edge], |
| 1849 | gap: GAP, | |
| 1850 | ..Spec::default() | |
| 1929 | gap: if theme.tool[3] > 0.0 && !panel { | |
| 1930 | FACE_GAP | |
| 1931 | } else { | |
| 1932 | GAP | |
| 1933 | }, | |
| 1934 | ..face | |
| 1851 | 1935 | }, |
| 1852 | 1936 | ); |
| 1853 | 1937 | self.tools(theme); |
| ... | ... | @@ -1861,6 +1945,7 @@ impl State { |
| 1861 | 1945 | }, |
| 1862 | 1946 | ); |
| 1863 | 1947 | } |
| 1948 | self.ui.close(); | |
| 1864 | 1949 | if title { |
| 1865 | 1950 | platform::window_controls(&mut self.ui, &self.window); |
| 1866 | 1951 | } |
| ... | ... | @@ -1878,8 +1963,7 @@ impl State { |
| 1878 | 1963 | .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); |
| 1879 | 1964 | if narrowest != self.min_width { |
| 1880 | 1965 | self.min_width = narrowest; |
| 1881 | self.window | |
| 1882 | .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1]))); | |
| 1966 | platform::set_min_size(&self.window, [narrowest, MIN_SIZE[1]]); | |
| 1883 | 1967 | } |
| 1884 | 1968 | Ok(()) |
| 1885 | 1969 | } |
| ... | ... | @@ -2006,7 +2090,7 @@ impl State { |
| 2006 | 2090 | "font", |
| 2007 | 2091 | Spec { |
| 2008 | 2092 | gap: 1.0, |
| 2009 | ..Spec::default() | |
| 2093 | ..tool_face(theme, grouped(theme), FACE_RISE) | |
| 2010 | 2094 | }, |
| 2011 | 2095 | ); |
| 2012 | 2096 | divider(ui, theme); |
| ... | ... | @@ -2705,7 +2789,7 @@ impl State { |
| 2705 | 2789 | eprintln!("{error}"); |
| 2706 | 2790 | } |
| 2707 | 2791 | if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, None).clicked { |
| 2708 | self.commands.push(Command::NewPage { under: None }); | |
| 2792 | self.choose(commands::Choice::Command(commands::Id::NewPage)); | |
| 2709 | 2793 | } |
| 2710 | 2794 | tip(&mut self.ui, commands::Id::NewPage); |
| 2711 | 2795 | let toggle = if self.pages_open { |
| ... | ... | @@ -2749,9 +2833,8 @@ impl State { |
| 2749 | 2833 | self.ui.close(); |
| 2750 | 2834 | return None; |
| 2751 | 2835 | } |
| 2752 | let found = self | |
| 2753 | .search | |
| 2754 | .found_in(&session.library.key(&session.tabs[session.tab].path)); | |
| 2836 | let found = self.search.found_in(&session.key()); | |
| 2837 | let unread = self.unread_pages(); | |
| 2755 | 2838 | let rounding = self.rounding(); |
| 2756 | 2839 | let dragged = self.dragged_page(); |
| 2757 | 2840 | let rows = page_rows( |
| ... | ... | @@ -2759,7 +2842,7 @@ impl State { |
| 2759 | 2842 | theme, |
| 2760 | 2843 | section, |
| 2761 | 2844 | session, |
| 2762 | &found, | |
| 2845 | [&found, &unread], | |
| 2763 | 2846 | (rounding, self.page_tabs_left), |
| 2764 | 2847 | self.renaming.as_mut(), |
| 2765 | 2848 | dragged, |
| ... | ... | @@ -2813,7 +2896,8 @@ impl State { |
| 2813 | 2896 | let response = match event { |
| 2814 | 2897 | ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, |
| 2815 | 2898 | ui::Event::Pressure(pressure) => { |
| 2816 | self.view.set_pressure(pressure.filter(|_| self.pen_pressure)); | |
| 2899 | self.view | |
| 2900 | .set_pressure(pressure.filter(|_| self.pen_pressure)); | |
| 2817 | 2901 | continue; |
| 2818 | 2902 | } |
| 2819 | 2903 | ui::Event::PointerLeft => self.view.pointer_left(), |
| ... | ... | @@ -2900,6 +2984,9 @@ impl State { |
| 2900 | 2984 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { |
| 2901 | 2985 | match command { |
| 2902 | 2986 | Command::OpenSection(library, path) => { |
| 2987 | if library.locked(&path) { | |
| 2988 | return self.show_locked(library, path); | |
| 2989 | } | |
| 2903 | 2990 | let tab = match &self.session { |
| 2904 | 2991 | // Back to the open section before another one opened. |
| 2905 | 2992 | Some(session) |
| ... | ... | @@ -2953,7 +3040,7 @@ impl State { |
| 2953 | 3040 | self.commands.push(Command::OpenPage(page)); |
| 2954 | 3041 | } |
| 2955 | 3042 | Command::CopyVersion { version, tab } => { |
| 2956 | let session = self.session.as_ref().ok_or("No section is open")?; | |
| 3043 | let session = self.session.as_mut().ok_or("No section is open")?; | |
| 2957 | 3044 | let page = match session.version { |
| 2958 | 3045 | Some(open) if open == version => { |
| 2959 | 3046 | session.section.version(session.space, open)? |
| ... | ... | @@ -2962,6 +3049,8 @@ impl State { |
| 2962 | 3049 | }; |
| 2963 | 3050 | if tab == session.tab { |
| 2964 | 3051 | session.section.import_page(&page, &self.author)?; |
| 3052 | session.pages = session.section.pages()?; | |
| 3053 | self.edited(Vec::new()); | |
| 2965 | 3054 | } else { |
| 2966 | 3055 | let library = Arc::clone(&session.library); |
| 2967 | 3056 | let path = session.tabs[tab].path.clone(); |
| ... | ... | @@ -3010,6 +3099,7 @@ impl State { |
| 3010 | 3099 | self.delete_pages(pages)?; |
| 3011 | 3100 | self.edited(Vec::new()); |
| 3012 | 3101 | } |
| 3102 | Command::Recycle(request) => self.recycle(request)?, | |
| 3013 | 3103 | Command::Pages(edits) => self.edit_pages(edits)?, |
| 3014 | 3104 | Command::MovePage { space, path } => { |
| 3015 | 3105 | self.move_page(space, path)?; |
| ... | ... | @@ -3108,11 +3198,7 @@ impl State { |
| 3108 | 3198 | let Some(mut opening) = self.opening.take() else { |
| 3109 | 3199 | return Ok(()); |
| 3110 | 3200 | }; |
| 3111 | let paper = canvas::gpu::Paper { | |
| 3112 | color: self.ui.theme.paper, | |
| 3113 | ink: self.ui.theme.paper_ink, | |
| 3114 | } | |
| 3115 | .colored(opening.editor.page_color()); | |
| 3201 | let paper = paper.colored(opening.editor.page_color()); | |
| 3116 | 3202 | if !self |
| 3117 | 3203 | .view |
| 3118 | 3204 | .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) |
| ... | ... | @@ -3151,23 +3237,15 @@ impl State { |
| 3151 | 3237 | .session |
| 3152 | 3238 | .as_ref() |
| 3153 | 3239 | .is_none_or(|open| open.library.location != session.library.location); |
| 3154 | // A notebook read again after a change replaces the one it was. | |
| 3155 | match self | |
| 3156 | .notebooks | |
| 3157 | .iter_mut() | |
| 3158 | .find(|library| library.location == session.library.location) | |
| 3159 | { | |
| 3160 | Some(library) => *library = Arc::clone(&session.library), | |
| 3161 | None => self.notebooks.push(Arc::clone(&session.library)), | |
| 3162 | } | |
| 3163 | if session.library.in_icloud() && session.library.downloading() { | |
| 3164 | self.fetch(Arc::clone(&session.library), None); | |
| 3165 | } | |
| 3240 | self.list(&session.library); | |
| 3166 | 3241 | // The section left stays open a while for coming back to; the notebook's |
| 3167 | 3242 | // background sync takes it over once it is closed. |
| 3243 | self.locked = None; | |
| 3168 | 3244 | if let Some(previous) = self.session.replace(*session) { |
| 3245 | let path = &previous.tabs[previous.tab].path; | |
| 3246 | let locked = self.leave(&previous.library, path); | |
| 3169 | 3247 | let library = &previous.library; |
| 3170 | if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { | |
| 3248 | if !locked && self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { | |
| 3171 | 3249 | library.keep(&previous.tabs[previous.tab].path, previous.section); |
| 3172 | 3250 | } else { |
| 3173 | 3251 | let section = previous.section; |
| ... | ... | @@ -3197,7 +3275,7 @@ impl State { |
| 3197 | 3275 | } |
| 3198 | 3276 | } |
| 3199 | 3277 | let place = self.session.as_ref().and_then(|session| { |
| 3200 | let key = session.library.key(&session.tabs[session.tab].path); | |
| 3278 | let key = session.key(); | |
| 3201 | 3279 | self.places.get(&(key, session.space)).copied() |
| 3202 | 3280 | }); |
| 3203 | 3281 | let left = self.view.open(opening.editor, Some(opening.scene), place); |
| ... | ... | @@ -3217,7 +3295,10 @@ impl State { |
| 3217 | 3295 | self.switching = None; |
| 3218 | 3296 | self.visited(); |
| 3219 | 3297 | if let Some((space, action)) = self.after_open.take() |
| 3220 | && let Some(session) = self.session.as_ref().filter(|session| session.space == space) | |
| 3298 | && let Some(session) = self | |
| 3299 | .session | |
| 3300 | .as_ref() | |
| 3301 | .filter(|session| session.space == space) | |
| 3221 | 3302 | { |
| 3222 | 3303 | let target = menus::Target::Page { |
| 3223 | 3304 | library: Arc::clone(&session.library), |
| ... | ... | @@ -3259,17 +3340,7 @@ impl State { |
| 3259 | 3340 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. |
| 3260 | 3341 | /// Without `path` the notebook has no sections left, and one of its shows none. |
| 3261 | 3342 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 3262 | if library.in_icloud() && library.downloading() { | |
| 3263 | self.fetch(Arc::clone(&library), None); | |
| 3264 | } | |
| 3265 | match self | |
| 3266 | .notebooks | |
| 3267 | .iter_mut() | |
| 3268 | .find(|open| open.location == library.location) | |
| 3269 | { | |
| 3270 | Some(open) => *open = Arc::clone(&library), | |
| 3271 | None => self.notebooks.push(Arc::clone(&library)), | |
| 3272 | } | |
| 3343 | self.list(&library); | |
| 3273 | 3344 | let shown = |shown: &Library| shown.location == library.location; |
| 3274 | 3345 | let Some(path) = path else { |
| 3275 | 3346 | if self.sectionless.as_deref().is_some_and(shown) |
| ... | ... | @@ -3317,6 +3388,18 @@ impl State { |
| 3317 | 3388 | Ok(()) |
| 3318 | 3389 | } |
| 3319 | 3390 | |
| 3391 | /// Lists `library` in place of the notebook read before at its location, or last, and | |
| 3392 | /// brings down what iCloud Drive keeps elsewhere of it. | |
| 3393 | fn list(&mut self, library: &Arc<Library>) { | |
| 3394 | match (self.notebooks.iter_mut()).find(|open| open.location == library.location) { | |
| 3395 | Some(open) => *open = Arc::clone(library), | |
| 3396 | None => self.notebooks.push(Arc::clone(library)), | |
| 3397 | } | |
| 3398 | if library.in_icloud() && library.downloading() { | |
| 3399 | self.fetch(Arc::clone(library), None); | |
| 3400 | } | |
| 3401 | } | |
| 3402 | ||
| 3320 | 3403 | /// Follows a page shown in place of another. |
| 3321 | 3404 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { |
| 3322 | 3405 | if let Err(error) = self.wear_theme() { |
| ... | ... | @@ -3369,7 +3452,14 @@ impl State { |
| 3369 | 3452 | .file_name() |
| 3370 | 3453 | .map(|name| name.to_string_lossy().into_owned()) |
| 3371 | 3454 | .unwrap_or_default(); |
| 3372 | format!("{} · {file}", session.title()) | |
| 3455 | // OneNote titles a page of the recycle bin so. | |
| 3456 | let binned = recycle::binned(&session.tabs[session.tab].path); | |
| 3457 | let bin = if binned { | |
| 3458 | " (Read-Only - Recycle Bin)" | |
| 3459 | } else { | |
| 3460 | "" | |
| 3461 | }; | |
| 3462 | format!("{}{bin} · {file}", session.title()) | |
| 3373 | 3463 | } |
| 3374 | 3464 | None if !self.temporary => "Snowbound".to_owned(), |
| 3375 | 3465 | None => { |
| ... | ... | @@ -3486,7 +3576,10 @@ impl State { |
| 3486 | 3576 | loop { |
| 3487 | 3577 | session.section.wake(); |
| 3488 | 3578 | if Instant::now() >= deadline |
| 3489 | || session.section.pending().is_ok_and(|pending| pending.is_empty()) | |
| 3579 | || session | |
| 3580 | .section | |
| 3581 | .pending() | |
| 3582 | .is_ok_and(|pending| pending.is_empty()) | |
| 3490 | 3583 | { |
| 3491 | 3584 | return; |
| 3492 | 3585 | } |
| ... | ... | @@ -3575,16 +3668,17 @@ impl State { |
| 3575 | 3668 | /// last poll. |
| 3576 | 3669 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { |
| 3577 | 3670 | // Every notebook's changes are taken, so none is reported again. |
| 3578 | let changed: Vec<String> = self | |
| 3579 | .notebooks | |
| 3580 | .iter() | |
| 3581 | .filter_map(|library| Some((library, library.background.as_ref()?))) | |
| 3582 | .flat_map(|(library, background)| { | |
| 3583 | background | |
| 3584 | .changed() | |
| 3585 | .into_iter() | |
| 3586 | .map(|path| library.key(&path)) | |
| 3671 | let reported: Vec<(Arc<Library>, Vec<String>)> = (self.notebooks.iter()) | |
| 3672 | .filter_map(|library| { | |
| 3673 | Some((Arc::clone(library), library.background.as_ref()?.changed())) | |
| 3587 | 3674 | }) |
| 3675 | .filter(|(_, paths)| !paths.is_empty()) | |
| 3676 | .collect(); | |
| 3677 | for (library, paths) in &reported { | |
| 3678 | self.sections_changed(library, paths.clone()); | |
| 3679 | } | |
| 3680 | let changed: Vec<String> = (reported.iter()) | |
| 3681 | .flat_map(|(library, paths)| paths.iter().map(|path| library.key(path))) | |
| 3588 | 3682 | .collect(); |
| 3589 | 3683 | if !changed.is_empty() { |
| 3590 | 3684 | self.sync_index(false, changed); |
| ... | ... | @@ -3597,17 +3691,16 @@ impl State { |
| 3597 | 3691 | let mut listed = false; |
| 3598 | 3692 | let mut changed = false; |
| 3599 | 3693 | let mut rejected = None; |
| 3694 | let mut elsewhere = Vec::new(); | |
| 3600 | 3695 | for event in session.section.events() { |
| 3601 | 3696 | use notebook::session::Event; |
| 3602 | 3697 | match event { |
| 3603 | 3698 | Event::Changed(spaces) => { |
| 3604 | 3699 | listed = true; |
| 3605 | 3700 | changed |= spaces.contains(&session.space); |
| 3606 | self.search.changed( | |
| 3607 | session.library.key(&session.tabs[session.tab].path), | |
| 3608 | session.section.replica(), | |
| 3609 | spaces, | |
| 3610 | ); | |
| 3701 | elsewhere.extend_from_slice(&spaces); | |
| 3702 | self.search | |
| 3703 | .changed(session.key(), session.section.replica(), spaces); | |
| 3611 | 3704 | } |
| 3612 | 3705 | Event::Rejected { spaces, error } => { |
| 3613 | 3706 | if spaces.contains(&session.space) { |
| ... | ... | @@ -3621,6 +3714,12 @@ impl State { |
| 3621 | 3714 | } |
| 3622 | 3715 | } |
| 3623 | 3716 | session.sync = session.section.sync_status()?; |
| 3717 | if !elsewhere.is_empty() { | |
| 3718 | self.changed_elsewhere(&elsewhere); | |
| 3719 | } | |
| 3720 | let Some(session) = &mut self.session else { | |
| 3721 | return Ok(()); | |
| 3722 | }; | |
| 3624 | 3723 | if listed { |
| 3625 | 3724 | let back = session.shown; |
| 3626 | 3725 | session.pages = session.section.pages()?; |
| ... | ... | @@ -3967,9 +4066,15 @@ impl State { |
| 3967 | 4066 | }) |
| 3968 | 4067 | .collect(); |
| 3969 | 4068 | trace_input(&("Draw", viewport.origin, viewport.scale)); |
| 4069 | // Where the system's material shows through, only what the chrome fills covers it. | |
| 4070 | let clear = if self.surface.translucent() { | |
| 4071 | [0.0; 4] | |
| 4072 | } else { | |
| 4073 | self.ui.theme.strip | |
| 4074 | }; | |
| 3970 | 4075 | let start = Instant::now(); |
| 3971 | 4076 | self.renderer |
| 3972 | .draw(target, self.surface.size, self.ui.theme.strip, &layers) | |
| 4077 | .draw(target, self.surface.size, clear, &layers) | |
| 3973 | 4078 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| 3974 | 4079 | lap("render", start); |
| 3975 | 4080 | Ok(()) |
| ... | ... | @@ -3988,9 +4093,6 @@ impl State { |
| 3988 | 4093 | self.surface.read(&self.renderer, offscreen) |
| 3989 | 4094 | } |
| 3990 | 4095 | |
| 3991 | /// Queues input for the next frame. A press on the strip moves the window: at once on | |
| 3992 | /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as | |
| 3993 | /// the window manager's move would swallow a second click; a double press zooms it. | |
| 3994 | 4096 | fn over_page(&self) -> bool { |
| 3995 | 4097 | let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); |
| 3996 | 4098 | let [x, y] = self.pointer; |
| ... | ... | @@ -4023,6 +4125,9 @@ impl State { |
| 4023 | 4125 | } |
| 4024 | 4126 | } |
| 4025 | 4127 | |
| 4128 | /// Queues input for the next frame. A press on the strip moves the window: at once on | |
| 4129 | /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as | |
| 4130 | /// the window manager's move would swallow a second click; a double press zooms it. | |
| 4026 | 4131 | fn input(&mut self, event: ui::Event) { |
| 4027 | 4132 | if let ui::Event::PointerMoved(point) = event { |
| 4028 | 4133 | self.pointer = point; |
| ... | ... | @@ -4335,8 +4440,8 @@ fn page_list_top() -> &'static str { |
| 4335 | 4440 | "top" |
| 4336 | 4441 | } |
| 4337 | 4442 | |
| 4338 | /// The page list's rows, a page's conflict pages beneath it while shown and the pages a | |
| 4339 | /// search `found` marked. A `dragged` page's tab follows the pointer over the others, which | |
| 4443 | /// The page list's rows, a page's conflict pages beneath it while shown, and `marked` the | |
| 4444 | /// pages a search found and those unread. A `dragged` page's tab follows the pointer over the others, which | |
| 4340 | 4445 | /// slide aside to open the gap it would land in; once let go it eases into its place. |
| 4341 | 4446 | #[allow(clippy::too_many_arguments)] |
| 4342 | 4447 | fn page_rows( |
| ... | ... | @@ -4344,7 +4449,7 @@ fn page_rows( |
| 4344 | 4449 | theme: &Theme, |
| 4345 | 4450 | section: &ui::Section, |
| 4346 | 4451 | session: &Session, |
| 4347 | found: &HashSet<ExGuid>, | |
| 4452 | marked: [&HashSet<ExGuid>; 2], | |
| 4348 | 4453 | shape: (f32, bool), |
| 4349 | 4454 | mut renaming: Option<&mut rename::Renaming>, |
| 4350 | 4455 | dragged: Option<PageDrag>, |
| ... | ... | @@ -4389,7 +4494,7 @@ fn page_rows( |
| 4389 | 4494 | theme, |
| 4390 | 4495 | section, |
| 4391 | 4496 | session, |
| 4392 | found, | |
| 4497 | marked, | |
| 4393 | 4498 | shape, |
| 4394 | 4499 | renaming.as_deref_mut(), |
| 4395 | 4500 | (space, title, *level), |
| ... | ... | @@ -4416,7 +4521,7 @@ fn page_rows( |
| 4416 | 4521 | theme, |
| 4417 | 4522 | section, |
| 4418 | 4523 | session, |
| 4419 | found, | |
| 4524 | marked, | |
| 4420 | 4525 | shape, |
| 4421 | 4526 | renaming, |
| 4422 | 4527 | (space, title, *level), |
| ... | ... | @@ -4437,7 +4542,7 @@ fn page_row( |
| 4437 | 4542 | theme: &Theme, |
| 4438 | 4543 | section: &ui::Section, |
| 4439 | 4544 | session: &Session, |
| 4440 | found: &HashSet<ExGuid>, | |
| 4545 | [found, unread]: [&HashSet<ExGuid>; 2], | |
| 4441 | 4546 | shape: (f32, bool), |
| 4442 | 4547 | renaming: Option<&mut rename::Renaming>, |
| 4443 | 4548 | (space, title, level): (&ExGuid, &String, u32), |
| ... | ... | @@ -4457,6 +4562,7 @@ fn page_row( |
| 4457 | 4562 | indent: level.saturating_sub(1), |
| 4458 | 4563 | conflicted: !versions.is_empty(), |
| 4459 | 4564 | found: found.contains(space), |
| 4565 | unread: unread.contains(space), | |
| 4460 | 4566 | renaming: renaming |
| 4461 | 4567 | .filter(|renaming| renaming.page(*space)) |
| 4462 | 4568 | .map(|renaming| &mut renaming.name), |
| ... | ... | @@ -4493,62 +4599,45 @@ fn page_row( |
| 4493 | 4599 | tab: ui::mix(section.tab, theme.chip, 0.6), |
| 4494 | 4600 | ..*section |
| 4495 | 4601 | }; |
| 4496 | if session.shown_history == Some(*space) { | |
| 4497 | for version in session.page_versions(*space) { | |
| 4498 | let label = history::label(version); | |
| 4499 | let tab = PageTab { | |
| 4500 | label: &label, | |
| 4501 | dim: false, | |
| 4502 | indent: level.saturating_sub(1), | |
| 4503 | conflicted: false, | |
| 4504 | found: false, | |
| 4505 | renaming: None, | |
| 4506 | shift, | |
| 4507 | lifted: false, | |
| 4602 | // Each row: its id, its label, whether it is open, and whether it is a page version. | |
| 4603 | let history = (session.page_versions(*space).iter()) | |
| 4604 | .filter(|_| session.shown_history == Some(*space)) | |
| 4605 | .map(|version| { | |
| 4606 | let open = session.version == Some(version.context); | |
| 4607 | (version.context, history::label(version), open, true) | |
| 4608 | }); | |
| 4609 | let conflicts = (versions.iter()) | |
| 4610 | .filter(|_| session.shown == Some(*space)) | |
| 4611 | .map(|version| { | |
| 4612 | let label = match version.created { | |
| 4613 | Some(created) => format!("{} {}", platform::short_date(created), version.user), | |
| 4614 | None => version.user.clone(), | |
| 4508 | 4615 | }; |
| 4509 | let selected = session.version == Some(version.context); | |
| 4510 | if selected { | |
| 4511 | rows.open = Some(ui.id(version.context)); | |
| 4512 | } | |
| 4513 | height += ROW; | |
| 4514 | if page_tab(ui, theme, &muted, &version.context, tab, selected, shape) | |
| 4515 | .0 | |
| 4516 | .clicked | |
| 4517 | && !selected | |
| 4518 | { | |
| 4519 | rows.version = Some((*space, version.context)); | |
| 4520 | } | |
| 4521 | } | |
| 4522 | } | |
| 4523 | if session.shown != Some(*space) { | |
| 4524 | return height; | |
| 4525 | } | |
| 4526 | for version in versions { | |
| 4527 | let label = match version.created { | |
| 4528 | Some(created) => format!("{} {}", platform::short_date(created), version.user), | |
| 4529 | None => version.user.clone(), | |
| 4530 | }; | |
| 4616 | (version.space, label, version.space == session.space, false) | |
| 4617 | }); | |
| 4618 | for (id, label, selected, version) in history.chain(conflicts) { | |
| 4531 | 4619 | let tab = PageTab { |
| 4532 | 4620 | label: &label, |
| 4533 | 4621 | dim: false, |
| 4534 | 4622 | indent: level.saturating_sub(1), |
| 4535 | 4623 | conflicted: false, |
| 4536 | 4624 | found: false, |
| 4625 | unread: false, | |
| 4537 | 4626 | renaming: None, |
| 4538 | 4627 | shift, |
| 4539 | 4628 | lifted: false, |
| 4540 | 4629 | }; |
| 4541 | let selected = version.space == session.space; | |
| 4542 | 4630 | if selected { |
| 4543 | rows.open = Some(ui.id(version.space)); | |
| 4631 | rows.open = Some(ui.id(id)); | |
| 4544 | 4632 | } |
| 4545 | 4633 | height += ROW; |
| 4546 | if page_tab(ui, theme, &muted, &version.space, tab, selected, shape) | |
| 4634 | let clicked = page_tab(ui, theme, &muted, &id, tab, selected, shape) | |
| 4547 | 4635 | .0 |
| 4548 | .clicked | |
| 4549 | && !selected | |
| 4550 | { | |
| 4551 | rows.clicked = Some(version.space); | |
| 4636 | .clicked; | |
| 4637 | if clicked && !selected && version { | |
| 4638 | rows.version = Some((*space, id)); | |
| 4639 | } else if clicked && !selected { | |
| 4640 | rows.clicked = Some(id); | |
| 4552 | 4641 | } |
| 4553 | 4642 | } |
| 4554 | 4643 | height |
| ... | ... | @@ -4564,6 +4653,8 @@ struct PageTab<'a> { |
| 4564 | 4653 | conflicted: bool, |
| 4565 | 4654 | /// A search found the page, which OneNote marks yellow. |
| 4566 | 4655 | found: bool, |
| 4656 | /// Another author changed it since it was last viewed, which OneNote sets bold. | |
| 4657 | unread: bool, | |
| 4567 | 4658 | /// The name typed in the tab's rename field, while it shows one. |
| 4568 | 4659 | renaming: Option<&'a mut String>, |
| 4569 | 4660 | /// How far down from its place in the list it is drawn. |
| ... | ... | @@ -4652,6 +4743,7 @@ fn page_tab( |
| 4652 | 4743 | Spec { |
| 4653 | 4744 | size: [fill(), px(ROW - ROW_GAP)], |
| 4654 | 4745 | text: Some(tab.label), |
| 4746 | bold: tab.unread, | |
| 4655 | 4747 | overflow: ui::Overflow::Ellipsis, |
| 4656 | 4748 | color: Some(color), |
| 4657 | 4749 | ..Spec::default() |
| ... | ... | @@ -4793,11 +4885,12 @@ fn group( |
| 4793 | 4885 | full: impl FnOnce(&mut Ui) -> Option<commands::Choice>, |
| 4794 | 4886 | folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>, |
| 4795 | 4887 | ) -> Option<commands::Choice> { |
| 4888 | let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE); | |
| 4796 | 4889 | ui.open( |
| 4797 | 4890 | part, |
| 4798 | 4891 | Spec { |
| 4799 | 4892 | fold: Some(priority), |
| 4800 | ..Spec::default() | |
| 4893 | ..face | |
| 4801 | 4894 | }, |
| 4802 | 4895 | ); |
| 4803 | 4896 | ui.open( |
| ... | ... | @@ -5010,6 +5103,26 @@ fn dropdown( |
| 5010 | 5103 | } |
| 5011 | 5104 | } |
| 5012 | 5105 | |
| 5106 | /// Opens the trailing row of a dialog, whose buttons then stand at its right. | |
| 5107 | fn buttons(ui: &mut Ui) { | |
| 5108 | ui.open( | |
| 5109 | "buttons", | |
| 5110 | Spec { | |
| 5111 | size: [fill(), children()], | |
| 5112 | pad: [0.0, 8.0], | |
| 5113 | gap: 8.0, | |
| 5114 | ..Spec::default() | |
| 5115 | }, | |
| 5116 | ); | |
| 5117 | ui.leaf( | |
| 5118 | "space", | |
| 5119 | Spec { | |
| 5120 | size: [fill(), px(1.0)], | |
| 5121 | ..Spec::default() | |
| 5122 | }, | |
| 5123 | ); | |
| 5124 | } | |
| 5125 | ||
| 5013 | 5126 | /// Names box `id` to assistive technology, where no tooltip does. |
| 5014 | 5127 | fn name(ui: &mut Ui, id: Id, label: &str) { |
| 5015 | 5128 | if let Some(node) = ui.access(id) { |
| ... | ... | @@ -5179,8 +5292,33 @@ fn title(id: commands::Id) -> &'static str { |
| 5179 | 5292 | commands::command(id).title.trim_end_matches('…') |
| 5180 | 5293 | } |
| 5181 | 5294 | |
| 5182 | /// The line leading a toolbar group, as far from its buttons as the groups are apart. | |
| 5295 | /// Whether each toolbar group stands on a face of its own. | |
| 5296 | fn grouped(theme: &Theme) -> bool { | |
| 5297 | theme.tool[3] > 0.0 && !theme.tool_panel | |
| 5298 | } | |
| 5299 | ||
| 5300 | /// The theme's face for tools where `shown`, padded beside its buttons and standing `rise` | |
| 5301 | /// past the box's top and bottom. | |
| 5302 | fn tool_face(theme: &Theme, shown: bool, rise: f32) -> Spec<'static> { | |
| 5303 | if !shown { | |
| 5304 | return Spec::default(); | |
| 5305 | } | |
| 5306 | Spec { | |
| 5307 | fill: Some(theme.tool), | |
| 5308 | border: Some(theme.chip), | |
| 5309 | radius: 4.0, | |
| 5310 | inset: [0.0, -rise, 0.0, -rise], | |
| 5311 | pad: [FACE_PAD, 0.0], | |
| 5312 | ..Spec::default() | |
| 5313 | } | |
| 5314 | } | |
| 5315 | ||
| 5316 | /// The line leading a toolbar group, as far from its buttons as the groups are apart; groups | |
| 5317 | /// on faces of their own stand apart without one. | |
| 5183 | 5318 | fn divider(ui: &mut Ui, theme: &Theme) { |
| 5319 | if grouped(theme) { | |
| 5320 | return; | |
| 5321 | } | |
| 5184 | 5322 | ui.leaf( |
| 5185 | 5323 | "divider", |
| 5186 | 5324 | Spec { |
| ... | ... | @@ -5312,147 +5450,103 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); |
| 5312 | 5450 | |
| 5313 | 5451 | impl ApplicationHandler<UserEvent> for App { |
| 5314 | 5452 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { |
| 5453 | let Some(state) = &mut self.state else { | |
| 5454 | match event { | |
| 5455 | UserEvent::Quit => self.close(event_loop), | |
| 5456 | // A cold launch's documents arrive before the window opens. | |
| 5457 | UserEvent::Open(paths) => self.opening.extend(paths), | |
| 5458 | _ => {} | |
| 5459 | } | |
| 5460 | return; | |
| 5461 | }; | |
| 5315 | 5462 | let event = match event { |
| 5463 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop), | |
| 5316 | 5464 | UserEvent::Picture(bytes) => { |
| 5317 | if let Some(state) = &mut self.state { | |
| 5318 | if let Err(error) = state.insert_picture(bytes, None) { | |
| 5319 | eprintln!("{error}"); | |
| 5320 | } | |
| 5321 | state.window.request_redraw(); | |
| 5465 | if let Err(error) = state.insert_picture(bytes, None) { | |
| 5466 | eprintln!("{error}"); | |
| 5322 | 5467 | } |
| 5323 | return; | |
| 5468 | return state.window.request_redraw(); | |
| 5324 | 5469 | } |
| 5325 | 5470 | UserEvent::InsertText(text) => { |
| 5326 | if let Some(state) = &mut self.state { | |
| 5327 | if state.ui.focused() == Some(page()) { | |
| 5328 | match state.view.insert_text(text) { | |
| 5329 | Ok(response) => state.respond(response), | |
| 5330 | Err(error) => eprintln!("{error}"), | |
| 5331 | } | |
| 5332 | state.window.request_redraw(); | |
| 5333 | } else { | |
| 5334 | state.input(ui::Event::Ime(Ime::Commit(text))); | |
| 5335 | } | |
| 5336 | } | |
| 5337 | return; | |
| 5338 | } | |
| 5339 | UserEvent::Quit => { | |
| 5340 | self.close(event_loop); | |
| 5341 | return; | |
| 5342 | } | |
| 5343 | UserEvent::Then(then) => { | |
| 5344 | if let Some(state) = &mut self.state { | |
| 5345 | if let Err(error) = then(state) { | |
| 5346 | eprintln!("{error}"); | |
| 5471 | if state.ui.focused() == Some(page()) { | |
| 5472 | match state.view.insert_text(text) { | |
| 5473 | Ok(response) => state.respond(response), | |
| 5474 | Err(error) => eprintln!("{error}"), | |
| 5347 | 5475 | } |
| 5348 | 5476 | state.window.request_redraw(); |
| 5477 | } else { | |
| 5478 | state.input(ui::Event::Ime(Ime::Commit(text))); | |
| 5349 | 5479 | } |
| 5350 | 5480 | return; |
| 5351 | 5481 | } |
| 5352 | UserEvent::ICloudFolder => { | |
| 5353 | if let Some(state) = &mut self.state { | |
| 5354 | state.list_icloud(); | |
| 5482 | UserEvent::Then(then) => { | |
| 5483 | if let Err(error) = then(state) { | |
| 5484 | eprintln!("{error}"); | |
| 5355 | 5485 | } |
| 5356 | return; | |
| 5486 | return state.window.request_redraw(); | |
| 5357 | 5487 | } |
| 5488 | UserEvent::ICloudFolder => return state.list_icloud(), | |
| 5358 | 5489 | UserEvent::ICloudAccount => { |
| 5359 | if let Some(state) = &mut self.state { | |
| 5360 | state.icloud_account_changed(); | |
| 5361 | state.window.request_redraw(); | |
| 5362 | } | |
| 5363 | return; | |
| 5490 | state.icloud_account_changed(); | |
| 5491 | return state.window.request_redraw(); | |
| 5364 | 5492 | } |
| 5365 | 5493 | UserEvent::Open(paths) => { |
| 5366 | match &mut self.state { | |
| 5367 | Some(state) => { | |
| 5368 | for path in paths { | |
| 5369 | state.open_path(&path); | |
| 5370 | } | |
| 5371 | state.window.set_minimized(false); | |
| 5372 | state.window.focus_window(); | |
| 5373 | state.window.request_redraw(); | |
| 5374 | } | |
| 5375 | // A cold launch's documents arrive before the window opens. | |
| 5376 | None => self.opening.extend(paths), | |
| 5494 | for path in paths { | |
| 5495 | state.open_path(&path); | |
| 5377 | 5496 | } |
| 5378 | return; | |
| 5497 | state.window.set_minimized(false); | |
| 5498 | state.window.focus_window(); | |
| 5499 | return state.window.request_redraw(); | |
| 5379 | 5500 | } |
| 5380 | 5501 | UserEvent::Update => { |
| 5381 | if let Some(state) = &mut self.state { | |
| 5382 | state.updated(); | |
| 5383 | state.window.request_redraw(); | |
| 5384 | } | |
| 5385 | return; | |
| 5502 | state.updated(); | |
| 5503 | return state.window.request_redraw(); | |
| 5386 | 5504 | } |
| 5387 | 5505 | UserEvent::Sync => { |
| 5388 | if let Some(state) = &mut self.state | |
| 5389 | && let Err(error) = state.synced() | |
| 5390 | { | |
| 5506 | if let Err(error) = state.synced() { | |
| 5391 | 5507 | eprintln!("{error}"); |
| 5392 | 5508 | } |
| 5393 | 5509 | return; |
| 5394 | 5510 | } |
| 5395 | UserEvent::Choose(choice) => { | |
| 5396 | if let Some(state) = &mut self.state { | |
| 5397 | state.choose(choice); | |
| 5398 | } | |
| 5399 | return; | |
| 5400 | } | |
| 5511 | UserEvent::Choose(choice) => return state.choose(choice), | |
| 5401 | 5512 | UserEvent::Appearance => { |
| 5402 | if let Some(state) = &mut self.state { | |
| 5403 | state.follow_color_scheme(); | |
| 5404 | state.window.request_redraw(); | |
| 5405 | } | |
| 5406 | return; | |
| 5407 | } | |
| 5408 | UserEvent::Redraw => { | |
| 5409 | if let Some(state) = &self.state { | |
| 5410 | state.window.request_redraw(); | |
| 5411 | } | |
| 5412 | return; | |
| 5513 | state.follow_color_scheme(); | |
| 5514 | return state.window.request_redraw(); | |
| 5413 | 5515 | } |
| 5516 | UserEvent::Redraw => return state.window.request_redraw(), | |
| 5414 | 5517 | UserEvent::Replay(replay) => { |
| 5415 | if let Some(state) = &mut self.state { | |
| 5416 | match replay { | |
| 5417 | Replay::Input(event) => state.input(event), | |
| 5418 | Replay::Pinch(factor) => { | |
| 5419 | if let Err(error) = state.pinch(factor) { | |
| 5420 | eprintln!("{error}"); | |
| 5421 | } | |
| 5422 | } | |
| 5423 | Replay::Snapshot(path) => state.snapshot = Some(path), | |
| 5424 | Replay::Accessibility(path) => { | |
| 5425 | if let Err(error) = state.write_accessibility(&path) { | |
| 5426 | eprintln!("{error}"); | |
| 5427 | } | |
| 5428 | } | |
| 5429 | Replay::Tick => {} | |
| 5430 | Replay::Appearance(appearance) => { | |
| 5431 | state.window.set_theme(Some(appearance)); | |
| 5432 | state.set_appearance(appearance); | |
| 5433 | } | |
| 5434 | Replay::Resize([width, height]) => { | |
| 5435 | let _ = state | |
| 5436 | .window | |
| 5437 | .request_inner_size(LogicalSize::new(width, height)); | |
| 5518 | match replay { | |
| 5519 | Replay::Input(event) => state.input(event), | |
| 5520 | Replay::Pinch(factor) => { | |
| 5521 | if let Err(error) = state.pinch(factor) { | |
| 5522 | eprintln!("{error}"); | |
| 5438 | 5523 | } |
| 5439 | Replay::Quit => { | |
| 5440 | self.close(event_loop); | |
| 5441 | return; | |
| 5524 | } | |
| 5525 | Replay::Snapshot(path) => state.snapshot = Some(path), | |
| 5526 | Replay::Accessibility(path) => { | |
| 5527 | if let Err(error) = state.write_accessibility(&path) { | |
| 5528 | eprintln!("{error}"); | |
| 5442 | 5529 | } |
| 5443 | 5530 | } |
| 5444 | // A covered window gets no redraws, so each step draws its own frame. | |
| 5445 | if let Err(error) = state.frame() { | |
| 5446 | eprintln!("{error}"); | |
| 5531 | Replay::Tick | Replay::Quit => {} | |
| 5532 | Replay::Appearance(appearance) => { | |
| 5533 | state.window.set_theme(Some(appearance)); | |
| 5534 | state.set_appearance(appearance); | |
| 5535 | } | |
| 5536 | Replay::Resize([width, height]) => { | |
| 5537 | let _ = state | |
| 5538 | .window | |
| 5539 | .request_inner_size(LogicalSize::new(width, height)); | |
| 5447 | 5540 | } |
| 5448 | 5541 | } |
| 5542 | // A covered window gets no redraws, so each step draws its own frame. | |
| 5543 | if let Err(error) = state.frame() { | |
| 5544 | eprintln!("{error}"); | |
| 5545 | } | |
| 5449 | 5546 | return; |
| 5450 | 5547 | } |
| 5451 | 5548 | UserEvent::Accessibility(event) => event, |
| 5452 | 5549 | }; |
| 5453 | let Some(state) = &mut self.state else { | |
| 5454 | return; | |
| 5455 | }; | |
| 5456 | 5550 | if event.window_id != state.window.id() { |
| 5457 | 5551 | return; |
| 5458 | 5552 | } |
| ... | ... | @@ -5679,9 +5773,10 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 5679 | 5773 | } |
| 5680 | 5774 | |
| 5681 | 5775 | /// Feeds a development script to the window from another thread, one command per line |
| 5682 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type | |
| 5683 | /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, | |
| 5684 | /// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | |
| 5776 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, | |
| 5777 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] | |
| 5778 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`, | |
| 5779 | /// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | |
| 5685 | 5780 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 5686 | 5781 | let mut steps = Vec::new(); |
| 5687 | 5782 | for line in script.lines().filter(|line| !line.trim().is_empty()) { |
crates/snowbound/src/manage.rs+45-35| ... | ... | @@ -27,6 +27,8 @@ pub enum Structure { |
| 27 | 27 | Delete { |
| 28 | 28 | path: String, |
| 29 | 29 | }, |
| 30 | /// Deletes for good what the recycle bin holds. | |
| 31 | EmptyRecycleBin, | |
| 30 | 32 | /// Moves a section or group into another folder. |
| 31 | 33 | Move { |
| 32 | 34 | path: String, |
| ... | ... | @@ -55,6 +57,12 @@ pub enum Structure { |
| 55 | 57 | name: String, |
| 56 | 58 | color: Option<u32>, |
| 57 | 59 | }, |
| 60 | /// Sets, changes or removes a section's password: `key` opens it as it stands. | |
| 61 | Password { | |
| 62 | path: String, | |
| 63 | key: Option<onestore::protected::Key>, | |
| 64 | password: Option<zeroize::Zeroizing<String>>, | |
| 65 | }, | |
| 58 | 66 | } |
| 59 | 67 | |
| 60 | 68 | /// New iCloud Notebook while it is open: the name typed, and why the last was refused. |
| ... | ... | @@ -234,7 +242,7 @@ impl State { |
| 234 | 242 | } |
| 235 | 243 | |
| 236 | 244 | /// Applies `ops` to the open page as one edit and shows the result. |
| 237 | fn edit_page(&mut self, ops: Vec<PageOp>) -> Result<(), Box<dyn Error>> { | |
| 245 | pub(crate) fn edit_page(&mut self, ops: Vec<PageOp>) -> Result<(), Box<dyn Error>> { | |
| 238 | 246 | if ops.is_empty() { |
| 239 | 247 | return Ok(()); |
| 240 | 248 | } |
| ... | ... | @@ -247,6 +255,7 @@ impl State { |
| 247 | 255 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), |
| 248 | 256 | }, |
| 249 | 257 | )?; |
| 258 | self.edited(vec![space]); | |
| 250 | 259 | self.refresh() |
| 251 | 260 | } |
| 252 | 261 | |
| ... | ... | @@ -351,22 +360,7 @@ impl State { |
| 351 | 360 | }, |
| 352 | 361 | ); |
| 353 | 362 | } |
| 354 | ui.open( | |
| 355 | "buttons", | |
| 356 | Spec { | |
| 357 | size: [fill(), children()], | |
| 358 | pad: [0.0, 8.0], | |
| 359 | gap: 8.0, | |
| 360 | ..Spec::default() | |
| 361 | }, | |
| 362 | ); | |
| 363 | ui.leaf( | |
| 364 | "space", | |
| 365 | Spec { | |
| 366 | size: [fill(), px(1.0)], | |
| 367 | ..Spec::default() | |
| 368 | }, | |
| 369 | ); | |
| 363 | crate::buttons(ui); | |
| 370 | 364 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 371 | 365 | let create = ui::button(ui, "create", "Create").clicked || entered; |
| 372 | 366 | ui.close(); |
| ... | ... | @@ -660,23 +654,15 @@ impl State { |
| 660 | 654 | library.close_kept(); |
| 661 | 655 | let mut notebook = library.reopen()?; |
| 662 | 656 | let names = |notebook: &Notebook, folder: &str| -> Vec<String> { |
| 663 | let mut folders = vec![notebook.catalog()]; | |
| 664 | while let Some(candidate) = folders.pop() { | |
| 665 | if candidate.path == folder { | |
| 666 | return candidate | |
| 667 | .sections | |
| 668 | .iter() | |
| 669 | .map(|section| section.path.clone()) | |
| 670 | .chain(candidate.groups.iter().map(|group| group.path.clone())) | |
| 671 | .map(|path| { | |
| 672 | let name = path.rsplit('/').next().unwrap_or_default(); | |
| 673 | name.strip_suffix(".one").unwrap_or(name).to_owned() | |
| 674 | }) | |
| 675 | .collect(); | |
| 676 | } | |
| 677 | folders.extend(&candidate.groups); | |
| 678 | } | |
| 679 | Vec::new() | |
| 657 | let found = crate::library::folders(notebook.catalog(), |_| true) | |
| 658 | .into_iter() | |
| 659 | .find(|candidate| candidate.path == folder); | |
| 660 | found.map_or_else(Vec::new, |folder| { | |
| 661 | (folder.sections.iter().map(|section| &section.path)) | |
| 662 | .chain(folder.groups.iter().map(|group| &group.path)) | |
| 663 | .map(|path| crate::library::entry_name(path).to_owned()) | |
| 664 | .collect() | |
| 665 | }) | |
| 680 | 666 | }; |
| 681 | 667 | // A notebook not shown stays so through a change of its colours or name. |
| 682 | 668 | let stays = matches!( |
| ... | ... | @@ -709,6 +695,11 @@ impl State { |
| 709 | 695 | notebook.delete(&path)?; |
| 710 | 696 | (current, None) |
| 711 | 697 | } |
| 698 | // A section of the bin open goes with it. | |
| 699 | Structure::EmptyRecycleBin => { | |
| 700 | notebook.empty_recycle_bin()?; | |
| 701 | (current.filter(|path| !crate::recycle::binned(path)), None) | |
| 702 | } | |
| 712 | 703 | Structure::Move { path, folder } => { |
| 713 | 704 | let moved = notebook.move_entry(&path, &folder)?; |
| 714 | 705 | (current.map(|current| follow(&current, &path, &moved)), None) |
| ... | ... | @@ -732,6 +723,24 @@ impl State { |
| 732 | 723 | notebook.set_section_color(&path, color)?; |
| 733 | 724 | (current, None) |
| 734 | 725 | } |
| 726 | Structure::Password { | |
| 727 | path, | |
| 728 | key, | |
| 729 | password, | |
| 730 | } => { | |
| 731 | let key = notebook.set_password( | |
| 732 | &path, | |
| 733 | key.as_ref(), | |
| 734 | password.as_deref().map(String::as_str), | |
| 735 | )?; | |
| 736 | let library = Arc::new(library.with(notebook)); | |
| 737 | if let Some(key) = key { | |
| 738 | library.keep_key(&path, key); | |
| 739 | } | |
| 740 | let section = library.open(&path, notify)?; | |
| 741 | let (session, page) = read_session(section, library, path, None)?; | |
| 742 | return Ok(Loaded::Section(Box::new(session), page)); | |
| 743 | } | |
| 735 | 744 | Structure::Properties { name, color } => { |
| 736 | 745 | if name != library.name { |
| 737 | 746 | library.set_display_name(&name)?; |
| ... | ... | @@ -1187,7 +1196,8 @@ mod tests { |
| 1187 | 1196 | let root = temporary.join("Emptied"); |
| 1188 | 1197 | let location = root.to_str().unwrap(); |
| 1189 | 1198 | let cache = temporary.join("cache"); |
| 1190 | let mut notebook = Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); | |
| 1199 | let mut notebook = | |
| 1200 | Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); | |
| 1191 | 1201 | notebook.create_group("", "Group").unwrap(); |
| 1192 | 1202 | notebook.create_section("Group", "Inner", &dated()).unwrap(); |
| 1193 | 1203 |
crates/snowbound/src/media_linux.rs+2-22| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | //! with gst-libav), the default microphone records as PCM WAV, and the default camera with |
| 4 | 4 | //! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins. |
| 5 | 5 | |
| 6 | use crate::video; | |
| 6 | use crate::{platform::symbol, video}; | |
| 7 | 7 | use std::{ |
| 8 | 8 | cell::Cell, |
| 9 | 9 | ffi::{CStr, CString, c_char, c_int, c_void}, |
| ... | ... | @@ -50,12 +50,6 @@ struct Gst { |
| 50 | 50 | unsafe impl Send for Gst {} |
| 51 | 51 | unsafe impl Sync for Gst {} |
| 52 | 52 | |
| 53 | /// The function `name` in `library`, of C signature `F`. | |
| 54 | unsafe fn symbol<F: Copy>(library: *mut c_void, name: &CStr) -> Option<F> { | |
| 55 | let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; | |
| 56 | (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) }) | |
| 57 | } | |
| 58 | ||
| 59 | 53 | /// GStreamer, initialized; none where it is not installed. |
| 60 | 54 | fn gst() -> Option<&'static Gst> { |
| 61 | 55 | static GST: OnceLock<Option<Gst>> = OnceLock::new(); |
| ... | ... | @@ -84,20 +78,6 @@ fn gst() -> Option<&'static Gst> { |
| 84 | 78 | .as_ref() |
| 85 | 79 | } |
| 86 | 80 | |
| 87 | /// `path` as a `file://` URI, every byte outside the unreserved set escaped. | |
| 88 | fn uri(path: &Path) -> String { | |
| 89 | let mut uri = String::from("file://"); | |
| 90 | for byte in path.as_os_str().as_encoded_bytes() { | |
| 91 | match byte { | |
| 92 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { | |
| 93 | uri.push(char::from(*byte)); | |
| 94 | } | |
| 95 | _ => uri.push_str(&format!("%{byte:02X}")), | |
| 96 | } | |
| 97 | } | |
| 98 | uri | |
| 99 | } | |
| 100 | ||
| 101 | 81 | /// A pipeline described as `gst-launch` takes one, paused. |
| 102 | 82 | struct Pipeline { |
| 103 | 83 | gst: &'static Gst, |
| ... | ... | @@ -191,7 +171,7 @@ impl Player { |
| 191 | 171 | }; |
| 192 | 172 | let pipeline = Pipeline::launch(&format!( |
| 193 | 173 | "playbin uri=\"{}\" video-sink=fakesink{silent}", |
| 194 | uri(path) | |
| 174 | crate::platform::file_uri(path) | |
| 195 | 175 | ))?; |
| 196 | 176 | // Prerolling reports a file no plugin decodes as an error. |
| 197 | 177 | if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) { |
crates/snowbound/src/menubar.rs+12| ... | ... | @@ -67,6 +67,18 @@ const MENUS: &[Item] = &[ |
| 67 | 67 | C(Id::CopyPageLink), |
| 68 | 68 | C(Id::ShowNotebook), |
| 69 | 69 | S, |
| 70 | C(Id::PasswordProtect), | |
| 71 | C(Id::LockAll), | |
| 72 | S, | |
| 73 | C(Id::MarkRead), | |
| 74 | C(Id::MarkNotebookRead), | |
| 75 | C(Id::NextUnread), | |
| 76 | C(Id::ShowUnread), | |
| 77 | S, | |
| 78 | C(Id::RecycleBin), | |
| 79 | C(Id::EmptyRecycleBin), | |
| 80 | S, | |
| 81 | C(Id::SaveAs), | |
| 70 | 82 | C(Id::ExportPdf), |
| 71 | 83 | S, |
| 72 | 84 | C(Id::Print), |
crates/snowbound/src/menus.rs+128-21| ... | ... | @@ -6,6 +6,7 @@ use crate::{ |
| 6 | 6 | commands::{self, Choice}, |
| 7 | 7 | manage::Structure, |
| 8 | 8 | platform, |
| 9 | recycle::Request, | |
| 9 | 10 | }; |
| 10 | 11 | use onestore::{ExGuid, PageEdit}; |
| 11 | 12 | use std::sync::Arc; |
| ... | ... | @@ -87,6 +88,15 @@ pub enum Action { |
| 87 | 88 | Move, |
| 88 | 89 | /// Moves it to a section, or into a folder, by catalog path. |
| 89 | 90 | MoveTo(String), |
| 91 | /// Opens the submenu of the sections `CopyTo` copies a page of the recycle bin to. | |
| 92 | Copy, | |
| 93 | CopyTo(String), | |
| 94 | /// Save As on a section or notebook. | |
| 95 | SaveAs, | |
| 96 | /// Shows the notebook's recycle bin. | |
| 97 | RecycleBin, | |
| 98 | EmptyRecycleBin, | |
| 99 | MarkNotebookRead, | |
| 90 | 100 | CopyLink, |
| 91 | 101 | NewPage, |
| 92 | 102 | NewSubpage, |
| ... | ... | @@ -110,6 +120,8 @@ pub enum Action { |
| 110 | 120 | /// Moves a notebook up the notebook list, or down. |
| 111 | 121 | Raise(bool), |
| 112 | 122 | Properties, |
| 123 | /// Opens Password Protection on a section. | |
| 124 | Password, | |
| 113 | 125 | } |
| 114 | 126 | |
| 115 | 127 | /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section |
| ... | ... | @@ -259,7 +271,7 @@ impl State { |
| 259 | 271 | pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { |
| 260 | 272 | // An action, its label, whether it is disabled, and whether a rule starts its group. |
| 261 | 273 | let item = |action: Action, text, disabled, separated| { |
| 262 | let submenu = matches!(action, Action::Move | Action::Colors); | |
| 274 | let submenu = matches!(action, Action::Move | Action::Copy | Action::Colors); | |
| 263 | 275 | let item = Item { |
| 264 | 276 | text, |
| 265 | 277 | disabled, |
| ... | ... | @@ -271,6 +283,18 @@ impl State { |
| 271 | 283 | }; |
| 272 | 284 | let nowhere = || self.destinations(target).is_empty(); |
| 273 | 285 | match target { |
| 286 | // OneNote's Move or Copy takes a page out of the bin; nothing else changes it. | |
| 287 | Target::Page { path, .. } if crate::recycle::binned(path) => vec![ | |
| 288 | item(Action::Delete, "Delete", false, false), | |
| 289 | item(Action::Move, "Restore To", nowhere(), true), | |
| 290 | item(Action::Copy, "Copy To", nowhere(), false), | |
| 291 | ], | |
| 292 | Target::Section { path, .. } if crate::recycle::binned(path) => vec![ | |
| 293 | item(Action::SaveAs, "Save As…", false, false), | |
| 294 | item(Action::Move, "Restore To", nowhere(), false), | |
| 295 | item(Action::EmptyRecycleBin, "Empty Recycle Bin", false, true), | |
| 296 | item(Action::Colors, "Section Color", false, true), | |
| 297 | ], | |
| 274 | 298 | Target::Page { |
| 275 | 299 | library, |
| 276 | 300 | path, |
| ... | ... | @@ -321,16 +345,26 @@ impl State { |
| 321 | 345 | actions |
| 322 | 346 | } |
| 323 | 347 | Target::Section { library, path } | Target::Group { library, path } => { |
| 324 | let mut actions = vec![ | |
| 325 | item(Action::Rename, "Rename", false, false), | |
| 348 | let section = matches!(target, Target::Section { .. }); | |
| 349 | let mut actions = vec![item(Action::Rename, "Rename", false, false)]; | |
| 350 | if section { | |
| 351 | actions.push(item(Action::SaveAs, "Save As…", false, false)); | |
| 352 | } | |
| 353 | actions.extend([ | |
| 326 | 354 | item(Action::Delete, "Delete", false, false), |
| 327 | 355 | item(Action::Move, "Move", nowhere(), false), |
| 328 | 356 | item(Action::NewSection, "New Section", false, true), |
| 329 | 357 | item(Action::NewGroup, "New Section Group", false, false), |
| 330 | ]; | |
| 331 | if matches!(target, Target::Section { .. }) { | |
| 358 | ]); | |
| 359 | if section { | |
| 332 | 360 | actions.extend([ |
| 333 | item(Action::Colors, "Section Color", false, true), | |
| 361 | item( | |
| 362 | Action::Password, | |
| 363 | "Password Protect This Section…", | |
| 364 | library.catalog().is_none(), | |
| 365 | true, | |
| 366 | ), | |
| 367 | item(Action::Colors, "Section Color", false, false), | |
| 334 | 368 | item( |
| 335 | 369 | Action::Reveal, |
| 336 | 370 | platform::SHOW_FILE, |
| ... | ... | @@ -345,8 +379,15 @@ impl State { |
| 345 | 379 | Target::Notebook(library) => { |
| 346 | 380 | let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); |
| 347 | 381 | let last = self.notebooks.len().saturating_sub(1); |
| 382 | let unread = self.unread_notebook(library); | |
| 348 | 383 | vec![ |
| 349 | 384 | item(Action::Rename, "Rename…", false, false), |
| 385 | item( | |
| 386 | Action::SaveAs, | |
| 387 | "Save As…", | |
| 388 | library.catalog().is_none(), | |
| 389 | false, | |
| 390 | ), | |
| 350 | 391 | item( |
| 351 | 392 | Action::Sync, |
| 352 | 393 | "Sync This Notebook Now", |
| ... | ... | @@ -369,6 +410,12 @@ impl State { |
| 369 | 410 | false, |
| 370 | 411 | false, |
| 371 | 412 | ), |
| 413 | item( | |
| 414 | Action::MarkNotebookRead, | |
| 415 | "Mark Notebook as Read", | |
| 416 | !unread, | |
| 417 | true, | |
| 418 | ), | |
| 372 | 419 | item( |
| 373 | 420 | Action::Raise(true), |
| 374 | 421 | "Move Up", |
| ... | ... | @@ -388,6 +435,12 @@ impl State { |
| 388 | 435 | true, |
| 389 | 436 | ), |
| 390 | 437 | item(Action::Theme, "Theme…", false, false), |
| 438 | item( | |
| 439 | Action::RecycleBin, | |
| 440 | "Notebook Recycle Bin", | |
| 441 | library.catalog().is_none(), | |
| 442 | true, | |
| 443 | ), | |
| 391 | 444 | item( |
| 392 | 445 | Action::Properties, |
| 393 | 446 | "Properties…", |
| ... | ... | @@ -416,13 +469,22 @@ impl State { |
| 416 | 469 | let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect(); |
| 417 | 470 | let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); |
| 418 | 471 | let places = id.child("move"); |
| 472 | let copies = id.child("copy"); | |
| 419 | 473 | let colors = id.child("colors"); |
| 420 | 474 | ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { |
| 421 | 475 | Action::Move => Some(places), |
| 476 | Action::Copy => Some(copies), | |
| 422 | 477 | Action::Colors => Some(colors), |
| 423 | 478 | _ => None, |
| 424 | 479 | }); |
| 425 | let moved = if self.ui.popup_open(places) { | |
| 480 | let mut moved = None; | |
| 481 | for (submenu, chosen) in [ | |
| 482 | (places, Action::MoveTo as fn(String) -> Action), | |
| 483 | (copies, Action::CopyTo), | |
| 484 | ] { | |
| 485 | if !self.ui.popup_open(submenu) { | |
| 486 | continue; | |
| 487 | } | |
| 426 | 488 | let destinations = self.destinations(target); |
| 427 | 489 | let items: Vec<Item> = (destinations.iter()) |
| 428 | 490 | .map(|place| Item { |
| ... | ... | @@ -432,11 +494,9 @@ impl State { |
| 432 | 494 | ..Item::default() |
| 433 | 495 | }) |
| 434 | 496 | .collect(); |
| 435 | ui::popup::menu(&mut self.ui, places, anchor, &items, None) | |
| 436 | .map(|index| Action::MoveTo(destinations[index].path.clone())) | |
| 437 | } else { | |
| 438 | None | |
| 439 | }; | |
| 497 | moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None) | |
| 498 | .map(|index| chosen(destinations[index].path.clone()))); | |
| 499 | } | |
| 440 | 500 | let colored = match target { |
| 441 | 501 | Target::Section { library, path } if self.ui.popup_open(colors) => { |
| 442 | 502 | let current = (library.tabs(&folder(path)).into_iter()) |
| ... | ... | @@ -474,6 +534,17 @@ impl State { |
| 474 | 534 | fn destinations(&self, target: &Target) -> Vec<Destination> { |
| 475 | 535 | let theme = &self.ui.theme; |
| 476 | 536 | match target { |
| 537 | Target::Page { library, path, .. } if crate::recycle::binned(path) => { | |
| 538 | (folders(library).into_iter()) | |
| 539 | .flat_map(|place| library.tabs(&place.path)) | |
| 540 | .map(|tab| Destination { | |
| 541 | tint: Some(theme.section(crate::section_color(tab.color)).accent), | |
| 542 | name: tab.name, | |
| 543 | path: tab.path, | |
| 544 | icon: crate::art::SECTION, | |
| 545 | }) | |
| 546 | .collect() | |
| 547 | } | |
| 477 | 548 | Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) |
| 478 | 549 | .filter(|tab| tab.path != *path) |
| 479 | 550 | .map(|tab| Destination { |
| ... | ... | @@ -536,6 +607,33 @@ impl State { |
| 536 | 607 | (Target::Section { library, path }, Action::Open) => { |
| 537 | 608 | Some(Command::OpenSection(library, path)) |
| 538 | 609 | } |
| 610 | (Target::Section { library, path }, Action::SaveAs) => { | |
| 611 | self.open_save_as(library, Some(path), crate::save_as::Scope::Section); | |
| 612 | None | |
| 613 | } | |
| 614 | (Target::Notebook(library), Action::SaveAs) => { | |
| 615 | self.open_save_as(library, None, crate::save_as::Scope::Notebook); | |
| 616 | None | |
| 617 | } | |
| 618 | ( | |
| 619 | Target::Section { library, .. } | Target::Notebook(library), | |
| 620 | Action::EmptyRecycleBin, | |
| 621 | ) => { | |
| 622 | self.empty_recycle_bin(library); | |
| 623 | None | |
| 624 | } | |
| 625 | (Target::Notebook(library), Action::RecycleBin) => { | |
| 626 | self.toggle_recycle_bin(library); | |
| 627 | None | |
| 628 | } | |
| 629 | (Target::Notebook(library), Action::MarkNotebookRead) => { | |
| 630 | self.mark_notebook_read(&library); | |
| 631 | None | |
| 632 | } | |
| 633 | (Target::Section { library, path }, Action::Password) => { | |
| 634 | self.password_protection(library, path); | |
| 635 | None | |
| 636 | } | |
| 539 | 637 | (Target::Notebook(library), Action::Open) => { |
| 540 | 638 | self.open_notebook(library.location.clone(), None); |
| 541 | 639 | None |
| ... | ... | @@ -665,7 +763,22 @@ impl State { |
| 665 | 763 | self.rename(crate::rename::Target::Page(space)); |
| 666 | 764 | None |
| 667 | 765 | } |
| 766 | Action::Delete if self.in_recycle_bin() => { | |
| 767 | Some(Command::Recycle(Request::Purge(vec![space]))) | |
| 768 | } | |
| 668 | 769 | Action::Delete => Some(Command::DeletePages(vec![space])), |
| 770 | Action::MoveTo(path) if self.in_recycle_bin() => { | |
| 771 | Some(Command::Recycle(Request::Restore { | |
| 772 | space, | |
| 773 | path, | |
| 774 | copy: false, | |
| 775 | })) | |
| 776 | } | |
| 777 | Action::CopyTo(path) => Some(Command::Recycle(Request::Restore { | |
| 778 | space, | |
| 779 | path, | |
| 780 | copy: true, | |
| 781 | })), | |
| 669 | 782 | Action::MoveTo(path) => Some(Command::MovePage { space, path }), |
| 670 | 783 | Action::CopyLink => { |
| 671 | 784 | let copied = self |
| ... | ... | @@ -915,15 +1028,9 @@ fn new(action: &Action, folder: String) -> Option<Structure> { |
| 915 | 1028 | /// `library`'s folders of sections, the notebook's first, then its section groups breadth |
| 916 | 1029 | /// first, but for the recycle bin; none for a section opened on its own. |
| 917 | 1030 | pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> { |
| 918 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); | |
| 919 | let mut at = 0; | |
| 920 | while let Some(&folder) = folders.get(at) { | |
| 921 | folders.extend( | |
| 922 | (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)), | |
| 923 | ); | |
| 924 | at += 1; | |
| 925 | } | |
| 926 | folders | |
| 1031 | library.catalog().map_or_else(Vec::new, |catalog| { | |
| 1032 | crate::library::folders(catalog, |group| !crate::library::recycle_bin(&group.path)) | |
| 1033 | }) | |
| 927 | 1034 | } |
| 928 | 1035 | |
| 929 | 1036 | #[cfg(test)] |
crates/snowbound/src/navigation.rs+1| ... | ... | @@ -288,6 +288,7 @@ mod tests { |
| 288 | 288 | identity: None, |
| 289 | 289 | created: None, |
| 290 | 290 | margin_origin: [0.0; 2], |
| 291 | rtl: false, | |
| 291 | 292 | color: None, |
| 292 | 293 | rule_lines: None, |
| 293 | 294 | objects: Vec::new(), |
crates/snowbound/src/options.rs+66| ... | ... | @@ -125,6 +125,28 @@ const SECTIONS: &[Section] = &[ |
| 125 | 125 | control: Control::Check(|options| &mut options.pen_pressure), |
| 126 | 126 | }], |
| 127 | 127 | }, |
| 128 | Group { | |
| 129 | heading: "Passwords", | |
| 130 | rows: &[ | |
| 131 | Row { | |
| 132 | label: "Lock password protected sections after I have not worked in \ | |
| 133 | them for:", | |
| 134 | keywords: "protect protected lock unlock idle minutes timeout security", | |
| 135 | control: Control::Check(|options| &mut options.lock_idle), | |
| 136 | }, | |
| 137 | Row { | |
| 138 | label: "Amount of time:", | |
| 139 | keywords: "password protect protected lock idle minutes hours timeout", | |
| 140 | control: Control::Field(lock_after), | |
| 141 | }, | |
| 142 | Row { | |
| 143 | label: "Lock password protected sections as soon as I navigate away \ | |
| 144 | from them", | |
| 145 | keywords: "password protect protected lock leave switch security", | |
| 146 | control: Control::Check(|options| &mut options.lock_on_leave), | |
| 147 | }, | |
| 148 | ], | |
| 149 | }, | |
| 128 | 150 | ], |
| 129 | 151 | }, |
| 130 | 152 | Section { |
| ... | ... | @@ -210,6 +232,10 @@ pub struct Options { |
| 210 | 232 | default_font: DefaultFont, |
| 211 | 233 | page_tabs_left: bool, |
| 212 | 234 | navigation_bar_left: bool, |
| 235 | lock_idle: bool, | |
| 236 | /// Minutes, as `protection::AFTER` lists them. | |
| 237 | lock_minutes: u32, | |
| 238 | lock_on_leave: bool, | |
| 213 | 239 | pub(crate) keyboard: crate::keys::Keyboard, |
| 214 | 240 | } |
| 215 | 241 | |
| ... | ... | @@ -241,6 +267,10 @@ fn sizes() -> Id { |
| 241 | 267 | id().child("sizes") |
| 242 | 268 | } |
| 243 | 269 | |
| 270 | fn lock_times() -> Id { | |
| 271 | id().child("lock-times") | |
| 272 | } | |
| 273 | ||
| 244 | 274 | fn font_colors() -> Id { |
| 245 | 275 | id().child("font-colors") |
| 246 | 276 | } |
| ... | ... | @@ -298,6 +328,9 @@ impl State { |
| 298 | 328 | default_font: self.view.editor.default_font.clone(), |
| 299 | 329 | page_tabs_left: self.page_tabs_left, |
| 300 | 330 | navigation_bar_left: !self.navigation_bar_right, |
| 331 | lock_idle: self.passwords.lock_after.is_some(), | |
| 332 | lock_minutes: self.passwords.lock_after.unwrap_or(10), | |
| 333 | lock_on_leave: self.passwords.lock_on_leave, | |
| 301 | 334 | keyboard: crate::keys::Keyboard::new(), |
| 302 | 335 | }); |
| 303 | 336 | self.ui.open_popup(id()); |
| ... | ... | @@ -600,6 +633,10 @@ impl State { |
| 600 | 633 | self.view.editor.default_font = options.default_font; |
| 601 | 634 | self.page_tabs_left = options.page_tabs_left; |
| 602 | 635 | self.navigation_bar_right = !options.navigation_bar_left; |
| 636 | self.passwords = crate::settings::Passwords { | |
| 637 | lock_after: options.lock_idle.then_some(options.lock_minutes), | |
| 638 | lock_on_leave: options.lock_on_leave, | |
| 639 | }; | |
| 603 | 640 | self.updates.set_automatic(options.automatic_updates); |
| 604 | 641 | self.show_spelling(); |
| 605 | 642 | self.follow_color_scheme(); |
| ... | ... | @@ -679,6 +716,35 @@ fn appearance(state: &mut State, options: &mut Options) { |
| 679 | 716 | } |
| 680 | 717 | } |
| 681 | 718 | |
| 719 | fn lock_after(state: &mut State, options: &mut Options) { | |
| 720 | let ui = &mut state.ui; | |
| 721 | let combo = ui.id("combo"); | |
| 722 | let current = crate::protection::AFTER | |
| 723 | .iter() | |
| 724 | .find(|(minutes, _)| *minutes == options.lock_minutes) | |
| 725 | .map_or("", |(_, name)| name); | |
| 726 | ui::shell::combo( | |
| 727 | ui, | |
| 728 | "combo", | |
| 729 | "Amount of time", | |
| 730 | current, | |
| 731 | 140.0, | |
| 732 | lock_times(), | |
| 733 | true, | |
| 734 | ); | |
| 735 | let items = crate::protection::AFTER.map(|(minutes, name)| Item { | |
| 736 | text: name, | |
| 737 | checked: Some(minutes == options.lock_minutes), | |
| 738 | current: minutes == options.lock_minutes, | |
| 739 | ..Item::default() | |
| 740 | }); | |
| 741 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | |
| 742 | if let Some(index) = ui::popup::menu(ui, lock_times(), anchor, &items, None) { | |
| 743 | options.lock_minutes = crate::protection::AFTER[index].0; | |
| 744 | options.lock_idle = true; | |
| 745 | } | |
| 746 | } | |
| 747 | ||
| 682 | 748 | fn user_name_field(state: &mut State, options: &mut Options) { |
| 683 | 749 | let ui = &mut state.ui; |
| 684 | 750 | let theme = &ui.theme; |
crates/snowbound/src/pane.rs+1-1| ... | ... | @@ -929,7 +929,7 @@ impl State { |
| 929 | 929 | /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. |
| 930 | 930 | fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> { |
| 931 | 931 | let session = self.session.as_ref(); |
| 932 | let open = session.map(|session| session.library.key(&session.tabs[session.tab].path)); | |
| 932 | let open = session.map(crate::Session::key); | |
| 933 | 933 | // The open page with its subpages, or the page it is a subpage of with its others. |
| 934 | 934 | let group: Vec<_> = session |
| 935 | 935 | .and_then(|session| { |
crates/snowbound/src/prefetch.rs+13| ... | ... | @@ -38,6 +38,11 @@ impl<K: PartialEq, V> Recent<K, V> { |
| 38 | 38 | self.entries.iter().any(|(held, _)| held == key) |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | /// Lets go of the entries `keep` refuses. | |
| 42 | pub fn retain(&mut self, keep: impl Fn(&K) -> bool) { | |
| 43 | self.entries.retain(|(key, _)| keep(key)); | |
| 44 | } | |
| 45 | ||
| 41 | 46 | pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> { |
| 42 | 47 | self.entries.iter_mut().map(|(_, value)| value) |
| 43 | 48 | } |
| ... | ... | @@ -143,6 +148,14 @@ impl Prefetch { |
| 143 | 148 | } |
| 144 | 149 | } |
| 145 | 150 | |
| 151 | /// Lets go of the scenes kept of the section `section` names, as a locked one's. | |
| 152 | pub fn forget(&mut self, section: &str) { | |
| 153 | if let Ok(mut scenes) = self.scenes.lock() { | |
| 154 | scenes.retain(|(kept, _)| kept != section); | |
| 155 | } | |
| 156 | self.asked.retain(|(asked, _)| asked != section); | |
| 157 | } | |
| 158 | ||
| 146 | 159 | /// Whether `target` is new since it was last asked for, noting it. |
| 147 | 160 | fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool { |
| 148 | 161 | let new = !self.asked.contains(&target); |
crates/snowbound/src/print.rs+167-28| ... | ... | @@ -146,6 +146,14 @@ impl State { |
| 146 | 146 | self.ui.open_popup(id()); |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | /// Opens Export as PDF on `scope`, as Save As's PDF does. | |
| 150 | pub(crate) fn export_pdf(&mut self, scope: Scope) { | |
| 151 | self.open_print(true); | |
| 152 | if let Some(dialog) = &mut self.printing.dialog { | |
| 153 | dialog.setup.scope = scope; | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 149 | 157 | /// Builds the dialog while it is open; Print or Export goes on with its choices, which |
| 150 | 158 | /// the next opening starts from. |
| 151 | 159 | pub(crate) fn print_dialog(&mut self) { |
| ... | ... | @@ -247,22 +255,7 @@ impl State { |
| 247 | 255 | setup.footer = FOOTERS[at]; |
| 248 | 256 | } |
| 249 | 257 | }); |
| 250 | ui.open( | |
| 251 | "buttons", | |
| 252 | Spec { | |
| 253 | size: [fill(), children()], | |
| 254 | pad: [0.0, 8.0], | |
| 255 | gap: 8.0, | |
| 256 | ..Spec::default() | |
| 257 | }, | |
| 258 | ); | |
| 259 | ui.leaf( | |
| 260 | "space", | |
| 261 | Spec { | |
| 262 | size: [fill(), px(1.0)], | |
| 263 | ..Spec::default() | |
| 264 | }, | |
| 265 | ); | |
| 258 | crate::buttons(ui); | |
| 266 | 259 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 267 | 260 | let action = if dialog.export { |
| 268 | 261 | "Export…" |
| ... | ... | @@ -349,6 +342,7 @@ impl State { |
| 349 | 342 | fit_width: setup.fit_width, |
| 350 | 343 | footer: setup.footer, |
| 351 | 344 | }; |
| 345 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); | |
| 352 | 346 | std::thread::spawn(move || { |
| 353 | 347 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { |
| 354 | 348 | let mut open = Some(open); |
| ... | ... | @@ -359,6 +353,7 @@ impl State { |
| 359 | 353 | None => replica.page(space).map_err(Into::into), |
| 360 | 354 | }) |
| 361 | 355 | .collect::<Result<Vec<Page>, Box<dyn Error>>>()?; |
| 356 | let this = if whole { printed(this) } else { this }; | |
| 362 | 357 | let mut this = Some((open_tab.1.clone(), this)); |
| 363 | 358 | let sections = if others.is_empty() { |
| 364 | 359 | this.into_iter().collect() |
| ... | ... | @@ -377,7 +372,7 @@ impl State { |
| 377 | 372 | .into_iter() |
| 378 | 373 | .map(|(space, ..)| section.page(space)) |
| 379 | 374 | .collect::<Result<Vec<_>, _>>()?; |
| 380 | Ok((name, pages)) | |
| 375 | Ok((name, printed(pages))) | |
| 381 | 376 | }) |
| 382 | 377 | .collect::<Result<Vec<_>, Box<dyn Error>>>()? |
| 383 | 378 | }; |
| ... | ... | @@ -419,18 +414,20 @@ fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> { |
| 419 | 414 | start..end |
| 420 | 415 | } |
| 421 | 416 | |
| 422 | /// Every readable section of `library`, catalog path and name, a folder's sections before | |
| 423 | /// its groups', as OneNote orders them. | |
| 417 | /// Every readable section of `library` but the recycle bin's, catalog path and name, in the | |
| 418 | /// order each folder's TOC lists its sections and groups, as OneNote's notebook PDF prints them. | |
| 424 | 419 | fn notebook_tabs(library: &Library) -> Vec<(String, String)> { |
| 425 | 420 | fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { |
| 426 | tabs.extend( | |
| 427 | library | |
| 428 | .tabs(&folder.path) | |
| 429 | .into_iter() | |
| 430 | .map(|tab| (tab.path, tab.name)), | |
| 431 | ); | |
| 432 | for group in &folder.groups { | |
| 433 | walk(library, group, tabs); | |
| 421 | let mut sections = library.tabs(&folder.path); | |
| 422 | for path in &folder.order { | |
| 423 | if let Some(at) = sections.iter().position(|tab| tab.path == *path) { | |
| 424 | let tab = sections.remove(at); | |
| 425 | tabs.push((tab.path, tab.name)); | |
| 426 | } else if let Some(group) = (folder.groups.iter()) | |
| 427 | .find(|group| group.path == *path && !crate::library::recycle_bin(&group.path)) | |
| 428 | { | |
| 429 | walk(library, group, tabs); | |
| 430 | } | |
| 434 | 431 | } |
| 435 | 432 | } |
| 436 | 433 | let mut tabs = Vec::new(); |
| ... | ... | @@ -441,8 +438,17 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { |
| 441 | 438 | tabs |
| 442 | 439 | } |
| 443 | 440 | |
| 441 | /// The pages of `pages` a section's or notebook's PDF prints: all but untitled ones holding | |
| 442 | /// only their date. | |
| 443 | fn printed(pages: Vec<Page>) -> Vec<Page> { | |
| 444 | pages | |
| 445 | .into_iter() | |
| 446 | .filter(|page| !crate::undo::blank(page)) | |
| 447 | .collect() | |
| 448 | } | |
| 449 | ||
| 444 | 450 | /// `title` as a file name: without the characters Windows and macOS refuse in one. |
| 445 | fn file_name(title: &str) -> String { | |
| 451 | pub(crate) fn file_name(title: &str) -> String { | |
| 446 | 452 | let name: String = title |
| 447 | 453 | .chars() |
| 448 | 454 | .map(|c| match c { |
| ... | ... | @@ -486,4 +492,137 @@ mod tests { |
| 486 | 492 | assert_eq!(group(&levels, 3), 3..4); |
| 487 | 493 | assert_eq!(group(&levels, 5), 4..6); |
| 488 | 494 | } |
| 495 | ||
| 496 | /// A notebook whose root lists section A, group G (holding C) and section B in that order, | |
| 497 | /// A holding a titled page, an untitled page with only its date and an untitled page with | |
| 498 | /// text, each read through the app's sections as printing reads them. | |
| 499 | fn ordered_notebook(temporary: &std::path::Path) -> Library { | |
| 500 | use notebook::session::Notebook; | |
| 501 | use onestore::{ | |
| 502 | PageCreation, | |
| 503 | op::{Edit, Op, SectionOp}, | |
| 504 | }; | |
| 505 | let _ = std::fs::remove_dir_all(temporary); | |
| 506 | std::fs::create_dir_all(temporary).unwrap(); | |
| 507 | let root = temporary.join("Ordered"); | |
| 508 | let cache = temporary.join("cache"); | |
| 509 | let page = |title: &str| { | |
| 510 | PageCreation::new(None, Some(title), "Author") | |
| 511 | .unwrap() | |
| 512 | .dated("Thursday, October 1, 2026", "12:30 AM") | |
| 513 | .unwrap() | |
| 514 | }; | |
| 515 | let mut notebook = | |
| 516 | Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page("A titled")).unwrap(); | |
| 517 | notebook.rename("New Section 1.one", "A").unwrap(); | |
| 518 | notebook.create_section("", "B", &page("B titled")).unwrap(); | |
| 519 | notebook.create_group("", "G").unwrap(); | |
| 520 | notebook | |
| 521 | .create_section("G", "C", &page("C titled")) | |
| 522 | .unwrap(); | |
| 523 | notebook.reorder("", &["A.one", "G", "B.one"]).unwrap(); | |
| 524 | let library = Library::created(root.to_str().unwrap(), notebook, &cache); | |
| 525 | let section = library.open("A.one", || {}).unwrap(); | |
| 526 | let (dated, written) = (page(""), page("")); | |
| 527 | let space = written.space(); | |
| 528 | let edit = |ops| Edit { | |
| 529 | at: crate::filetime(), | |
| 530 | ops, | |
| 531 | }; | |
| 532 | let create = |creation| Op::Section(SectionOp::Create(creation)); | |
| 533 | (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap(); | |
| 534 | let meeting = crate::templates::Choice::Template("Informal Meeting Notes"); | |
| 535 | let ops = crate::manage::template_ops(&section.page(space).unwrap(), meeting, Vec::new()) | |
| 536 | .unwrap() | |
| 537 | .into_iter() | |
| 538 | .map(|op| Op::Page { space, op }) | |
| 539 | .collect(); | |
| 540 | section.apply("Author", edit(ops)).unwrap(); | |
| 541 | // The template titles the page; its title goes, its text stays. | |
| 542 | let untitled = crate::rename::retitled(&section.page(space).unwrap(), space, String::new()); | |
| 543 | section | |
| 544 | .apply("Author", edit(vec![untitled.unwrap()])) | |
| 545 | .unwrap(); | |
| 546 | library.keep("A.one", section); | |
| 547 | library | |
| 548 | } | |
| 549 | ||
| 550 | /// A notebook exports in the order its tables of contents list sections and groups, as | |
| 551 | /// OneNote 2010's Save As, Notebook, PDF prints A, then G's C, then B, though its own | |
| 552 | /// navigation lists A, B, then G (lab, 2026-10-01). | |
| 553 | #[test] | |
| 554 | fn a_notebook_exports_in_the_order_it_lists_sections_and_groups() { | |
| 555 | let temporary = | |
| 556 | std::env::temp_dir().join(format!("snowbound-print-order-{}", std::process::id())); | |
| 557 | let library = ordered_notebook(&temporary); | |
| 558 | let tabs = notebook_tabs(&library); | |
| 559 | library.close_kept(); | |
| 560 | drop(library); | |
| 561 | let _ = std::fs::remove_dir_all(&temporary); | |
| 562 | let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect(); | |
| 563 | assert_eq!(paths, ["A.one", "G/C.one", "B.one"]); | |
| 564 | } | |
| 565 | ||
| 566 | /// An untitled page holding only its date is left out, and one holding text is printed, | |
| 567 | /// as OneNote 2010's Save As, Section and Notebook, PDF do (lab, 2026-10-01). | |
| 568 | #[test] | |
| 569 | fn untitled_pages_holding_only_their_date_are_left_out() { | |
| 570 | let temporary = | |
| 571 | std::env::temp_dir().join(format!("snowbound-print-blank-{}", std::process::id())); | |
| 572 | let library = ordered_notebook(&temporary); | |
| 573 | let section = library.open("A.one", || {}).unwrap(); | |
| 574 | let pages: Vec<Page> = (section.pages().unwrap().into_iter()) | |
| 575 | .map(|(space, ..)| section.page(space).unwrap()) | |
| 576 | .collect(); | |
| 577 | section.close().unwrap(); | |
| 578 | drop(library); | |
| 579 | let _ = std::fs::remove_dir_all(&temporary); | |
| 580 | assert_eq!(pages.len(), 3); | |
| 581 | // A page's title as typed, which its title object holds. | |
| 582 | let typed = |page: &Page| { | |
| 583 | page.objects | |
| 584 | .iter() | |
| 585 | .find_map(|object| match object { | |
| 586 | onestore::page::PageObject::Title(title) => { | |
| 587 | let text = title.outlines.first()?.paragraphs.first()?.text()?; | |
| 588 | Some(text.text.text().to_owned()) | |
| 589 | } | |
| 590 | _ => None, | |
| 591 | }) | |
| 592 | .unwrap_or_default() | |
| 593 | }; | |
| 594 | let printed: Vec<(String, usize)> = printed(pages) | |
| 595 | .iter() | |
| 596 | .map(|page| (typed(page), page.objects.len())) | |
| 597 | .collect(); | |
| 598 | assert_eq!(printed.len(), 2, "{printed:?}"); | |
| 599 | assert_eq!(printed[0].0, "A titled"); | |
| 600 | assert!(printed[1].0.is_empty() && printed[1].1 > 1, "{printed:?}"); | |
| 601 | } | |
| 602 | ||
| 603 | /// A notebook exported whole leaves out its recycle bin, as OneNote 2010's Save As, | |
| 604 | /// Notebook, PDF does. | |
| 605 | #[test] | |
| 606 | fn a_notebook_exports_without_its_recycle_bin() { | |
| 607 | use notebook::session::Notebook; | |
| 608 | let temporary = | |
| 609 | std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id())); | |
| 610 | let _ = std::fs::remove_dir_all(&temporary); | |
| 611 | std::fs::create_dir_all(&temporary).unwrap(); | |
| 612 | let root = temporary.join("Printed"); | |
| 613 | let cache = temporary.join("cache"); | |
| 614 | let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); | |
| 615 | let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); | |
| 616 | notebook.create_group("", "Group").unwrap(); | |
| 617 | notebook.create_section("Group", "Inner", &page()).unwrap(); | |
| 618 | notebook.create_section("", "Binned", &page()).unwrap(); | |
| 619 | notebook.delete("Binned.one").unwrap(); | |
| 620 | let library = Library::created(root.to_str().unwrap(), notebook, &cache); | |
| 621 | let paths: Vec<String> = notebook_tabs(&library) | |
| 622 | .into_iter() | |
| 623 | .map(|(path, _)| path) | |
| 624 | .collect(); | |
| 625 | std::fs::remove_dir_all(&temporary).unwrap(); | |
| 626 | assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]); | |
| 627 | } | |
| 489 | 628 | } |
crates/snowbound/src/properties.rs+2-18| ... | ... | @@ -151,27 +151,11 @@ impl State { |
| 151 | 151 | } |
| 152 | 152 | label(ui, "Location:"); |
| 153 | 153 | dim(ui, "location", &dialog.library.location); |
| 154 | let folder = dialog.library.folder().map(std::path::Path::to_owned); | |
| 155 | 154 | let reveal = commands::command(commands::Id::ShowNotebook).title; |
| 156 | if folder.is_some() && ui::button(ui, "reveal", reveal).clicked { | |
| 155 | if dialog.library.folder().is_some() && ui::button(ui, "reveal", reveal).clicked { | |
| 157 | 156 | crate::platform::reveal(&dialog.library.location); |
| 158 | 157 | } |
| 159 | ui.open( | |
| 160 | "buttons", | |
| 161 | Spec { | |
| 162 | size: [fill(), children()], | |
| 163 | pad: [0.0, 8.0], | |
| 164 | gap: 8.0, | |
| 165 | ..Spec::default() | |
| 166 | }, | |
| 167 | ); | |
| 168 | ui.leaf( | |
| 169 | "space", | |
| 170 | Spec { | |
| 171 | size: [fill(), px(1.0)], | |
| 172 | ..Spec::default() | |
| 173 | }, | |
| 174 | ); | |
| 158 | crate::buttons(ui); | |
| 175 | 159 | let ok = ui::button(ui, "ok", "OK").clicked || entered; |
| 176 | 160 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 177 | 161 | ui.close(); |
crates/snowbound/src/protection.rs created+642| ... | ... | @@ -0,0 +1,642 @@ |
| 1 | //! Password-protected sections as OneNote 2010 shows them: a locked section's page, the | |
| 2 | //! dialogs that unlock one and set, change or remove its password, Lock All, and locking a | |
| 3 | //! section after a while unused or as it is left. Keys live in memory only (`Library`). | |
| 4 | ||
| 5 | use crate::{Command, Library, State, Theme, art, manage::Structure}; | |
| 6 | use accesskit::Role; | |
| 7 | use std::{error::Error, sync::Arc, time::Duration}; | |
| 8 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; | |
| 9 | use winit::keyboard::NamedKey; | |
| 10 | use zeroize::Zeroizing; | |
| 11 | ||
| 12 | const WIDTH: f32 = 440.0; | |
| 13 | ||
| 14 | /// What Options' "Lock password protected sections after…" offers, in minutes, as | |
| 15 | /// OneNote 2010's list does. | |
| 16 | pub const AFTER: [(u32, &str); 11] = [ | |
| 17 | (1, "1 Minute"), | |
| 18 | (5, "5 Minutes"), | |
| 19 | (10, "10 Minutes"), | |
| 20 | (15, "15 Minutes"), | |
| 21 | (30, "30 Minutes"), | |
| 22 | (60, "1 Hour"), | |
| 23 | (120, "2 Hours"), | |
| 24 | (240, "4 Hours"), | |
| 25 | (480, "8 Hours"), | |
| 26 | (720, "12 Hours"), | |
| 27 | (1440, "1 Day"), | |
| 28 | ]; | |
| 29 | ||
| 30 | /// A password-protected section shown locked, in place of its pages. | |
| 31 | pub struct Locked { | |
| 32 | pub library: Arc<Library>, | |
| 33 | pub path: String, | |
| 34 | } | |
| 35 | ||
| 36 | /// A password dialog on a section. | |
| 37 | pub struct Asking { | |
| 38 | library: Arc<Library>, | |
| 39 | path: String, | |
| 40 | dialog: Dialog, | |
| 41 | } | |
| 42 | ||
| 43 | enum Dialog { | |
| 44 | /// Protected Section: the password that unlocks it; whether the last was wrong. | |
| 45 | Unlock(Zeroizing<String>, bool), | |
| 46 | /// Password Protection: whether it is protected, and the ways to change that. | |
| 47 | Protection, | |
| 48 | /// Sets a password: it, its confirmation, and why the last try was refused. | |
| 49 | Set([Zeroizing<String>; 2], Option<&'static str>), | |
| 50 | /// Change Password: the old password, the new one and its confirmation. | |
| 51 | Change([Zeroizing<String>; 3], Option<&'static str>), | |
| 52 | /// Remove Password: the current password. | |
| 53 | Remove(Zeroizing<String>, bool), | |
| 54 | } | |
| 55 | ||
| 56 | fn id() -> Id { | |
| 57 | Id::ROOT.child("password") | |
| 58 | } | |
| 59 | ||
| 60 | fn field(index: usize) -> Id { | |
| 61 | id().child(("field", index)) | |
| 62 | } | |
| 63 | ||
| 64 | /// The locked page's notice, which a click or Enter unlocks. | |
| 65 | fn notice() -> Id { | |
| 66 | Id::ROOT.child("locked-notice") | |
| 67 | } | |
| 68 | ||
| 69 | fn section_name(path: &str) -> &str { | |
| 70 | let name = path.rsplit('/').next().unwrap_or(path); | |
| 71 | name.strip_suffix(".one").unwrap_or(name) | |
| 72 | } | |
| 73 | ||
| 74 | impl State { | |
| 75 | /// Shows the locked section `path` of `library` in place of the open section, which stays | |
| 76 | /// open a while for coming back to. | |
| 77 | pub(crate) fn show_locked( | |
| 78 | &mut self, | |
| 79 | library: Arc<Library>, | |
| 80 | path: String, | |
| 81 | ) -> Result<(), Box<dyn Error>> { | |
| 82 | self.stop_loading(); | |
| 83 | self.persist()?; | |
| 84 | if let Some(previous) = self.session.take() { | |
| 85 | let path = &previous.tabs[previous.tab].path; | |
| 86 | if self.leave(&previous.library, path) { | |
| 87 | previous.section.close()?; | |
| 88 | } else { | |
| 89 | previous.library.keep(path, previous.section); | |
| 90 | } | |
| 91 | } | |
| 92 | self.sectionless = None; | |
| 93 | self.locked = Some(Locked { library, path }); | |
| 94 | self.ui.set_focus(Some(notice())); | |
| 95 | self.title(); | |
| 96 | Ok(()) | |
| 97 | } | |
| 98 | ||
| 99 | /// Locks `path` of `library` as it is left, where Options asks for that; whether it did. | |
| 100 | pub(crate) fn leave(&mut self, library: &Arc<Library>, path: &str) -> bool { | |
| 101 | if self.passwords.lock_on_leave && library.protected(path) { | |
| 102 | library.lock(|locked, _| locked == path); | |
| 103 | self.prefetch.forget(&library.key(path)); | |
| 104 | return true; | |
| 105 | } | |
| 106 | false | |
| 107 | } | |
| 108 | ||
| 109 | /// The section tabs of the locked section's folder, its own shown. | |
| 110 | pub(crate) fn locked_tabs( | |
| 111 | &mut self, | |
| 112 | row: Id, | |
| 113 | section: &ui::Section, | |
| 114 | strip: [f32; 4], | |
| 115 | ) -> (Option<Command>, Id) { | |
| 116 | let Some(locked) = &self.locked else { | |
| 117 | return (None, row); | |
| 118 | }; | |
| 119 | let library = Arc::clone(&locked.library); | |
| 120 | let tabs = library.tabs(&crate::menus::folder(&locked.path)); | |
| 121 | let shown = tabs | |
| 122 | .iter() | |
| 123 | .position(|tab| tab.path == locked.path) | |
| 124 | .unwrap_or(0); | |
| 125 | let names: Vec<_> = tabs | |
| 126 | .iter() | |
| 127 | .map(|tab| (tab.name.as_str(), crate::section_color(tab.color), false)) | |
| 128 | .collect(); | |
| 129 | let drawn = ui::shell::section_tabs( | |
| 130 | &mut self.ui, | |
| 131 | row, | |
| 132 | &names, | |
| 133 | shown, | |
| 134 | None, | |
| 135 | None, | |
| 136 | section, | |
| 137 | crate::TAB_ROW, | |
| 138 | strip, | |
| 139 | ); | |
| 140 | if let Some((tab, point)) = drawn.context { | |
| 141 | self.menu = Some(( | |
| 142 | crate::menus::Target::Section { | |
| 143 | library: Arc::clone(&library), | |
| 144 | path: tabs[tab].path.clone(), | |
| 145 | }, | |
| 146 | point, | |
| 147 | )); | |
| 148 | self.ui.open_popup(crate::menus::id()); | |
| 149 | } | |
| 150 | let clicked = drawn | |
| 151 | .clicked | |
| 152 | .filter(|tab| *tab != shown) | |
| 153 | .map(|tab| Command::OpenSection(library, tabs[tab].path.clone())); | |
| 154 | (clicked, drawn.open) | |
| 155 | } | |
| 156 | ||
| 157 | /// The locked section's page, as OneNote 2010 draws it: a click or Enter unlocks. | |
| 158 | pub(crate) fn locked_page(&mut self, theme: &Theme) { | |
| 159 | let Some(locked) = &self.locked else { | |
| 160 | return; | |
| 161 | }; | |
| 162 | let (library, path) = (Arc::clone(&locked.library), locked.path.clone()); | |
| 163 | let ui = &mut self.ui; | |
| 164 | let entered = | |
| 165 | ui::popup::navigation(ui, &[notice()], &[NamedKey::Enter]).contains(&NamedKey::Enter); | |
| 166 | let area = ui.open( | |
| 167 | "locked", | |
| 168 | Spec { | |
| 169 | size: [fill(), fill()], | |
| 170 | fill: Some(ui::mix(theme.paper, theme.chip, 0.6)), | |
| 171 | ..Spec::default() | |
| 172 | }, | |
| 173 | ); | |
| 174 | let [left, top, right, bottom] = ui.rect(area).unwrap_or_default(); | |
| 175 | let width = 380.0_f32.min(right - left); | |
| 176 | let open = ui.open_as( | |
| 177 | notice(), | |
| 178 | Spec { | |
| 179 | flags: Flags::FLOAT | Flags::CLICKABLE | Flags::FOCUSABLE, | |
| 180 | size: [px(width), children()], | |
| 181 | position: [ | |
| 182 | ((right - left - width) / 2.0).max(0.0), | |
| 183 | ((bottom - top) * 0.2).max(0.0), | |
| 184 | ], | |
| 185 | gap: 12.0, | |
| 186 | role: Some(Role::Button), | |
| 187 | ..Spec::default() | |
| 188 | }, | |
| 189 | ); | |
| 190 | if let Some(node) = ui.access(open) { | |
| 191 | node.set_label("This section is password protected. Unlock it"); | |
| 192 | } | |
| 193 | ui.leaf( | |
| 194 | "icon", | |
| 195 | Spec { | |
| 196 | size: [px(20.0), px(theme.font_size * 1.6)], | |
| 197 | icon: Some(art::INFO), | |
| 198 | ..Spec::default() | |
| 199 | }, | |
| 200 | ); | |
| 201 | ui.open( | |
| 202 | "words", | |
| 203 | Spec { | |
| 204 | axis: Axis::Y, | |
| 205 | size: [fill(), children()], | |
| 206 | gap: 14.0, | |
| 207 | ..Spec::default() | |
| 208 | }, | |
| 209 | ); | |
| 210 | let line = |ui: &mut Ui, part, text, bold| { | |
| 211 | ui.leaf( | |
| 212 | part, | |
| 213 | Spec { | |
| 214 | size: [fill(), px(theme.font_size * 1.6)], | |
| 215 | text: Some(text), | |
| 216 | bold, | |
| 217 | font_size: Some(theme.font_size * 1.15), | |
| 218 | ..Spec::default() | |
| 219 | }, | |
| 220 | ); | |
| 221 | }; | |
| 222 | line(ui, "title", "This section is password protected.", true); | |
| 223 | line( | |
| 224 | ui, | |
| 225 | "how", | |
| 226 | "Click here or press ENTER to unlock this section.", | |
| 227 | false, | |
| 228 | ); | |
| 229 | ui.close(); | |
| 230 | ui.close(); | |
| 231 | ui.close(); | |
| 232 | if ui.signal(open).clicked || entered { | |
| 233 | self.ask_password(library, path, Dialog::Unlock(Zeroizing::default(), false)); | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | /// The section shown, open or locked, with its notebook. | |
| 238 | pub(crate) fn shown_section(&self) -> Option<(Arc<Library>, String)> { | |
| 239 | match (&self.session, &self.locked) { | |
| 240 | (Some(session), _) if session.library.catalog().is_some() => Some(( | |
| 241 | Arc::clone(&session.library), | |
| 242 | session.tabs[session.tab].path.clone(), | |
| 243 | )), | |
| 244 | (None, Some(locked)) => Some((Arc::clone(&locked.library), locked.path.clone())), | |
| 245 | _ => None, | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | /// Opens Password Protection on the section at `path` of `library`, as its menu's | |
| 250 | /// Password Protect This Section… does. | |
| 251 | pub(crate) fn password_protection(&mut self, library: Arc<Library>, path: String) { | |
| 252 | self.ask_password(library, path, Dialog::Protection); | |
| 253 | } | |
| 254 | ||
| 255 | fn ask_password(&mut self, library: Arc<Library>, path: String, dialog: Dialog) { | |
| 256 | self.password = Some(Asking { | |
| 257 | library, | |
| 258 | path, | |
| 259 | dialog, | |
| 260 | }); | |
| 261 | self.ui.open_popup(id()); | |
| 262 | self.ui.set_focus(None); | |
| 263 | } | |
| 264 | ||
| 265 | /// Locks every protected section unlocked this run, as Lock All and Ctrl+Alt+L do. | |
| 266 | pub(crate) fn lock_all(&mut self) -> Result<(), Box<dyn Error>> { | |
| 267 | let mut locked = Vec::new(); | |
| 268 | for library in self.notebooks.clone() { | |
| 269 | let paths = library.lock(|_, _| true); | |
| 270 | locked.extend(paths.iter().map(|path| library.key(path))); | |
| 271 | } | |
| 272 | self.sync_index(true, locked); | |
| 273 | self.relock() | |
| 274 | } | |
| 275 | ||
| 276 | /// Locks the sections not worked in for as long as Options says, and wakes in time to | |
| 277 | /// lock the next. | |
| 278 | pub(crate) fn lock_idle(&mut self) -> Result<(), Box<dyn Error>> { | |
| 279 | let Some(minutes) = self.passwords.lock_after else { | |
| 280 | return Ok(()); | |
| 281 | }; | |
| 282 | let after = Duration::from_secs(u64::from(minutes) * 60); | |
| 283 | // Working in the open section keeps it unlocked. | |
| 284 | if self.changed | |
| 285 | && let Some(session) = &self.session | |
| 286 | { | |
| 287 | session.library.touch(&session.tabs[session.tab].path); | |
| 288 | } | |
| 289 | let (mut waiting, mut locked) = (false, Vec::new()); | |
| 290 | for library in self.notebooks.clone() { | |
| 291 | let paths = library.lock(|_, idle| { | |
| 292 | waiting |= idle < after; | |
| 293 | idle >= after | |
| 294 | }); | |
| 295 | locked.extend(paths.iter().map(|path| library.key(path))); | |
| 296 | } | |
| 297 | if waiting { | |
| 298 | self.ui.wake_after(Duration::from_secs(30)); | |
| 299 | } | |
| 300 | if !locked.is_empty() { | |
| 301 | self.sync_index(true, locked); | |
| 302 | } | |
| 303 | self.relock() | |
| 304 | } | |
| 305 | ||
| 306 | /// Shows the open section locked once its key is gone. | |
| 307 | fn relock(&mut self) -> Result<(), Box<dyn Error>> { | |
| 308 | let Some(session) = &self.session else { | |
| 309 | return Ok(()); | |
| 310 | }; | |
| 311 | let (library, path) = ( | |
| 312 | Arc::clone(&session.library), | |
| 313 | session.tabs[session.tab].path.clone(), | |
| 314 | ); | |
| 315 | if !library.locked(&path) { | |
| 316 | return Ok(()); | |
| 317 | } | |
| 318 | self.persist()?; | |
| 319 | if let Some(session) = self.session.take() { | |
| 320 | session.section.close()?; | |
| 321 | } | |
| 322 | self.prefetch.forget(&library.key(&path)); | |
| 323 | self.password = None; | |
| 324 | self.ui.close_popup(id()); | |
| 325 | self.show_locked(library, path) | |
| 326 | } | |
| 327 | ||
| 328 | /// The open password dialog; what its OK does. | |
| 329 | pub(crate) fn password_dialog(&mut self) -> Result<(), Box<dyn Error>> { | |
| 330 | let Some(mut asking) = self.password.take() else { | |
| 331 | return Ok(()); | |
| 332 | }; | |
| 333 | if !self.ui.popup_open(id()) { | |
| 334 | return Ok(()); | |
| 335 | } | |
| 336 | let theme = self.ui.theme.clone(); | |
| 337 | let row = theme.font_size * 2.0; | |
| 338 | let ui = &mut self.ui; | |
| 339 | let fields = match &asking.dialog { | |
| 340 | Dialog::Unlock(..) | Dialog::Remove(..) => 1, | |
| 341 | Dialog::Protection => 0, | |
| 342 | Dialog::Set(..) => 2, | |
| 343 | Dialog::Change(..) => 3, | |
| 344 | }; | |
| 345 | let owners: Vec<Id> = (0..fields).map(field).collect(); | |
| 346 | // A dialog asking for a password has one of its fields focused, the first at first. | |
| 347 | if !owners.is_empty() && !ui.focused().is_some_and(|focus| owners.contains(&focus)) { | |
| 348 | ui.set_focus(Some(field(0))); | |
| 349 | } | |
| 350 | let entered = | |
| 351 | ui::popup::navigation(ui, &owners, &[NamedKey::Enter]).contains(&NamedKey::Enter); | |
| 352 | let protected = asking.library.protected(&asking.path); | |
| 353 | let title = match asking.dialog { | |
| 354 | Dialog::Unlock(..) => "Protected Section", | |
| 355 | Dialog::Protection | Dialog::Set(..) => "Password Protection", | |
| 356 | Dialog::Change(..) => "Change Password", | |
| 357 | Dialog::Remove(..) => "Remove Password", | |
| 358 | }; | |
| 359 | ui.open_as( | |
| 360 | id(), | |
| 361 | Spec { | |
| 362 | axis: Axis::Y, | |
| 363 | size: [px(WIDTH), children()], | |
| 364 | fill: Some(theme.popup), | |
| 365 | border: Some(theme.chip), | |
| 366 | shadow: Some(theme.shadow), | |
| 367 | radius: 8.0, | |
| 368 | pad: [16.0, 12.0], | |
| 369 | gap: 8.0, | |
| 370 | anchor: Some(Anchor::Dialog), | |
| 371 | role: Some(Role::Dialog), | |
| 372 | ..Spec::default() | |
| 373 | }, | |
| 374 | ); | |
| 375 | if let Some(node) = ui.access(id()) { | |
| 376 | node.set_label(title); | |
| 377 | } | |
| 378 | let text = |ui: &mut Ui, part: &str, text: &str, bold| { | |
| 379 | ui.leaf( | |
| 380 | part, | |
| 381 | Spec { | |
| 382 | size: [fill(), px(row)], | |
| 383 | text: Some(text), | |
| 384 | bold, | |
| 385 | ..Spec::default() | |
| 386 | }, | |
| 387 | ); | |
| 388 | }; | |
| 389 | text(ui, "title", title, true); | |
| 390 | let name = section_name(&asking.path); | |
| 391 | let field_spec = Spec { | |
| 392 | size: [fill(), px(row)], | |
| 393 | fill: Some(theme.base), | |
| 394 | border: Some(theme.accent), | |
| 395 | radius: 4.0, | |
| 396 | pad: [6.0, 0.0], | |
| 397 | ..Spec::default() | |
| 398 | }; | |
| 399 | let password = |ui: &mut Ui, index, label: &str, value: &mut String| { | |
| 400 | text(ui, &format!("label {index}"), label, false); | |
| 401 | ui::password_field(ui, field(index), value, "", field_spec.clone()); | |
| 402 | if let Some(node) = ui.access(field(index)) { | |
| 403 | node.set_label(label.trim_end_matches(':')); | |
| 404 | } | |
| 405 | }; | |
| 406 | let problem = |ui: &mut Ui, problem: &str| { | |
| 407 | ui.leaf( | |
| 408 | "problem", | |
| 409 | Spec { | |
| 410 | size: [fill(), px(row)], | |
| 411 | icon: Some(art::WARNING), | |
| 412 | text: Some(problem), | |
| 413 | bold: true, | |
| 414 | gap: 6.0, | |
| 415 | role: Some(Role::Alert), | |
| 416 | ..Spec::default() | |
| 417 | }, | |
| 418 | ); | |
| 419 | }; | |
| 420 | let mut buttons: &[&str] = &["Cancel", "OK"]; | |
| 421 | match &mut asking.dialog { | |
| 422 | Dialog::Unlock(value, wrong) => { | |
| 423 | text( | |
| 424 | ui, | |
| 425 | "about", | |
| 426 | &format!("Section \u{201c}{name}\u{201d} is password protected."), | |
| 427 | false, | |
| 428 | ); | |
| 429 | if *wrong { | |
| 430 | problem(ui, "Password is incorrect."); | |
| 431 | } | |
| 432 | password(ui, 0, "Enter Password:", value); | |
| 433 | } | |
| 434 | Dialog::Protection => { | |
| 435 | let status = if protected { | |
| 436 | format!("Section \u{201c}{name}\u{201d} is password protected.") | |
| 437 | } else { | |
| 438 | format!("Section \u{201c}{name}\u{201d} is not password protected.") | |
| 439 | }; | |
| 440 | text(ui, "status", &status, false); | |
| 441 | buttons = if protected { | |
| 442 | &["Change Password…", "Remove Password…", "Lock All", "Close"] | |
| 443 | } else { | |
| 444 | &["Set Password…", "Lock All", "Close"] | |
| 445 | }; | |
| 446 | } | |
| 447 | Dialog::Set([value, confirm], refused) => { | |
| 448 | if let Some(refused) = refused { | |
| 449 | problem(ui, refused); | |
| 450 | } | |
| 451 | password(ui, 0, "Enter Password:", value); | |
| 452 | password(ui, 1, "Confirm Password:", confirm); | |
| 453 | text(ui, "caution", "Caution", true); | |
| 454 | ui.leaf( | |
| 455 | "caution-text", | |
| 456 | Spec { | |
| 457 | size: [fill(), ui::fit()], | |
| 458 | text: Some( | |
| 459 | "If you lose or forget the password, Snowbound cannot recover \ | |
| 460 | your data. (Remember that passwords are case-sensitive.)", | |
| 461 | ), | |
| 462 | overflow: ui::Overflow::Wrap, | |
| 463 | ..Spec::default() | |
| 464 | }, | |
| 465 | ); | |
| 466 | } | |
| 467 | Dialog::Change([old, value, confirm], refused) => { | |
| 468 | if let Some(refused) = refused { | |
| 469 | problem(ui, refused); | |
| 470 | } | |
| 471 | password(ui, 0, "Old Password:", old); | |
| 472 | password(ui, 1, "Enter New Password:", value); | |
| 473 | password(ui, 2, "Confirm Password:", confirm); | |
| 474 | } | |
| 475 | Dialog::Remove(value, wrong) => { | |
| 476 | if *wrong { | |
| 477 | problem(ui, "Password is incorrect."); | |
| 478 | } | |
| 479 | password( | |
| 480 | ui, | |
| 481 | 0, | |
| 482 | "To remove password protection, enter the current password:", | |
| 483 | value, | |
| 484 | ); | |
| 485 | } | |
| 486 | } | |
| 487 | ui.open( | |
| 488 | "buttons", | |
| 489 | Spec { | |
| 490 | size: [fill(), children()], | |
| 491 | gap: 8.0, | |
| 492 | ..Spec::default() | |
| 493 | }, | |
| 494 | ); | |
| 495 | ui.leaf( | |
| 496 | "space", | |
| 497 | Spec { | |
| 498 | size: [fill(), px(1.0)], | |
| 499 | ..Spec::default() | |
| 500 | }, | |
| 501 | ); | |
| 502 | let pressed = buttons | |
| 503 | .iter() | |
| 504 | .position(|label| ui::button(ui, *label, label).clicked); | |
| 505 | ui.close(); | |
| 506 | ui.close(); | |
| 507 | let chosen = pressed.map(|at| buttons[at]).or(entered.then_some("OK")); | |
| 508 | let Asking { | |
| 509 | library, | |
| 510 | path, | |
| 511 | dialog, | |
| 512 | } = asking; | |
| 513 | let next = match (chosen, dialog) { | |
| 514 | (None, dialog) => { | |
| 515 | self.password = Some(Asking { | |
| 516 | library, | |
| 517 | path, | |
| 518 | dialog, | |
| 519 | }); | |
| 520 | return Ok(()); | |
| 521 | } | |
| 522 | (Some("Cancel" | "Close"), _) => None, | |
| 523 | (Some("Lock All"), _) => { | |
| 524 | self.ui.close_popup(id()); | |
| 525 | return self.lock_all(); | |
| 526 | } | |
| 527 | (Some("Set Password…"), _) => Some(Dialog::Set(Default::default(), None)), | |
| 528 | (Some("Change Password…"), _) => Some(Dialog::Change(Default::default(), None)), | |
| 529 | (Some("Remove Password…"), _) => Some(Dialog::Remove(Zeroizing::default(), false)), | |
| 530 | (Some(_), Dialog::Unlock(value, _)) => match library.unlock(&path, &value) { | |
| 531 | Ok(()) => { | |
| 532 | self.commands | |
| 533 | .push(Command::OpenSection(Arc::clone(&library), path.clone())); | |
| 534 | None | |
| 535 | } | |
| 536 | Err(notebook::Error::Protected(_)) => { | |
| 537 | Some(Dialog::Unlock(Zeroizing::default(), true)) | |
| 538 | } | |
| 539 | Err(error) => { | |
| 540 | crate::platform::alert("Couldn't unlock the section", &error.to_string()); | |
| 541 | None | |
| 542 | } | |
| 543 | }, | |
| 544 | (Some(_), Dialog::Set([value, confirm], _)) => { | |
| 545 | if value.is_empty() { | |
| 546 | Some(Dialog::Set(Default::default(), Some("Enter a password."))) | |
| 547 | } else if *value != *confirm { | |
| 548 | Some(Dialog::Set( | |
| 549 | Default::default(), | |
| 550 | Some("The passwords don\u{2019}t match."), | |
| 551 | )) | |
| 552 | } else { | |
| 553 | self.set_password(library.clone(), path.clone(), None, Some(value)); | |
| 554 | None | |
| 555 | } | |
| 556 | } | |
| 557 | (Some(_), Dialog::Change([old, value, confirm], _)) => { | |
| 558 | if value.is_empty() { | |
| 559 | Some(Dialog::Change( | |
| 560 | Default::default(), | |
| 561 | Some("Enter a password."), | |
| 562 | )) | |
| 563 | } else if *value != *confirm { | |
| 564 | Some(Dialog::Change( | |
| 565 | Default::default(), | |
| 566 | Some("The new passwords don\u{2019}t match."), | |
| 567 | )) | |
| 568 | } else { | |
| 569 | match current(&library, &path, &old) { | |
| 570 | Some(key) => { | |
| 571 | self.set_password( | |
| 572 | library.clone(), | |
| 573 | path.clone(), | |
| 574 | Some(key), | |
| 575 | Some(value), | |
| 576 | ); | |
| 577 | None | |
| 578 | } | |
| 579 | None => Some(Dialog::Change( | |
| 580 | Default::default(), | |
| 581 | Some("Password is incorrect."), | |
| 582 | )), | |
| 583 | } | |
| 584 | } | |
| 585 | } | |
| 586 | (Some(_), Dialog::Remove(value, _)) => match current(&library, &path, &value) { | |
| 587 | Some(key) => { | |
| 588 | self.set_password(library.clone(), path.clone(), Some(key), None); | |
| 589 | None | |
| 590 | } | |
| 591 | None => Some(Dialog::Remove(Zeroizing::default(), true)), | |
| 592 | }, | |
| 593 | (Some(_), Dialog::Protection) => None, | |
| 594 | }; | |
| 595 | match next { | |
| 596 | Some(dialog) => self.ask_password(library, path, dialog), | |
| 597 | None => self.ui.close_popup(id()), | |
| 598 | } | |
| 599 | Ok(()) | |
| 600 | } | |
| 601 | ||
| 602 | /// Sets, changes or removes the password of `path` in `library`, closing its section | |
| 603 | /// first: the section is written anew, and its replica goes with the old file. | |
| 604 | fn set_password( | |
| 605 | &mut self, | |
| 606 | library: Arc<Library>, | |
| 607 | path: String, | |
| 608 | key: Option<onestore::protected::Key>, | |
| 609 | password: Option<Zeroizing<String>>, | |
| 610 | ) { | |
| 611 | let open = self.session.as_ref().is_some_and(|session| { | |
| 612 | Arc::ptr_eq(&session.library, &library) && session.tabs[session.tab].path == path | |
| 613 | }); | |
| 614 | if open { | |
| 615 | let closed = self.persist().and_then(|()| { | |
| 616 | if let Some(session) = self.session.take() { | |
| 617 | session.section.close()?; | |
| 618 | } | |
| 619 | Ok(()) | |
| 620 | }); | |
| 621 | if let Err(error) = closed { | |
| 622 | return crate::platform::alert("Couldn't set the password", &error.to_string()); | |
| 623 | } | |
| 624 | self.prefetch.forget(&library.key(&path)); | |
| 625 | } | |
| 626 | library.lock(|locked, _| locked == path); | |
| 627 | self.restructure( | |
| 628 | library, | |
| 629 | Structure::Password { | |
| 630 | path, | |
| 631 | key, | |
| 632 | password, | |
| 633 | }, | |
| 634 | ); | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | /// The key `password` opens the section at `path` with, if it does. | |
| 639 | fn current(library: &Library, path: &str, password: &str) -> Option<onestore::protected::Key> { | |
| 640 | library.unlock(path, password).ok()?; | |
| 641 | library.unlocked(path) | |
| 642 | } |
crates/snowbound/src/recycle.rs created+393| ... | ... | @@ -0,0 +1,393 @@ |
| 1 | //! OneNote 2010's Notebook Recycle Bin: the notebook's `OneNote_RecycleBin` group shown as a | |
| 2 | //! row of its own tabs, Deleted Pages and each deleted section, read-only beneath a bar | |
| 3 | //! saying how to restore them. Pages go back to a section and sections to a folder, and Empty | |
| 4 | //! Recycle Bin deletes what the bin holds for good. | |
| 5 | ||
| 6 | use crate::{Command, Library, Loaded, State, filetime, library, menus, notify, read_session}; | |
| 7 | use onestore::{ | |
| 8 | ExGuid, | |
| 9 | op::{Edit, Op, SectionOp}, | |
| 10 | }; | |
| 11 | use std::{error::Error, sync::Arc}; | |
| 12 | use ui::{Axis, Spec, Ui, children, fill, fit}; | |
| 13 | ||
| 14 | /// The bin's folder, a section group of the notebook's. | |
| 15 | pub const BIN: &str = "OneNote_RecycleBin"; | |
| 16 | ||
| 17 | /// What the bin's commands ask of the section it shows. | |
| 18 | pub enum Request { | |
| 19 | /// Puts page `space` at the end of the section at catalog `path`, or with `copy` a copy | |
| 20 | /// of it, as OneNote's Move or Copy takes a page out of the bin. | |
| 21 | Restore { | |
| 22 | space: ExGuid, | |
| 23 | path: String, | |
| 24 | copy: bool, | |
| 25 | }, | |
| 26 | /// Deletes pages for good. | |
| 27 | Purge(Vec<ExGuid>), | |
| 28 | } | |
| 29 | ||
| 30 | /// Whether the section or group at catalog `path` lies in its notebook's recycle bin. | |
| 31 | pub fn binned(path: &str) -> bool { | |
| 32 | library::recycle_bin(&menus::folder(path)) | |
| 33 | } | |
| 34 | ||
| 35 | impl State { | |
| 36 | /// Whether the open section is one of the recycle bin's. | |
| 37 | pub(crate) fn in_recycle_bin(&self) -> bool { | |
| 38 | (self.session.as_ref()).is_some_and(|session| binned(&session.tabs[session.tab].path)) | |
| 39 | } | |
| 40 | ||
| 41 | /// Notebook Recycle Bin: shows `library`'s bin at its first section holding pages, or | |
| 42 | /// while it shows, goes back to the notebook. | |
| 43 | pub(crate) fn toggle_recycle_bin(&mut self, library: Arc<Library>) { | |
| 44 | if self.in_recycle_bin() { | |
| 45 | if let Some(path) = library.first_section() { | |
| 46 | self.commands.push(Command::OpenSection(library, path)); | |
| 47 | } | |
| 48 | return; | |
| 49 | } | |
| 50 | let paths: Vec<String> = library.tabs(BIN).into_iter().map(|tab| tab.path).collect(); | |
| 51 | let proxy = self.proxy.clone(); | |
| 52 | self.load(move || { | |
| 53 | for path in paths { | |
| 54 | let section = library.open(&path, notify(proxy.clone()))?; | |
| 55 | if section.pages()?.is_empty() { | |
| 56 | section.close()?; | |
| 57 | continue; | |
| 58 | } | |
| 59 | let (session, page) = read_session(section, library, path, None)?; | |
| 60 | return Ok(Loaded::Section(Box::new(session), page)); | |
| 61 | } | |
| 62 | Err("The Recycle Bin is empty.".into()) | |
| 63 | }); | |
| 64 | } | |
| 65 | ||
| 66 | /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. | |
| 67 | pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) { | |
| 68 | if crate::platform::confirm( | |
| 69 | "Are you sure you want to empty the Recycle Bin for this notebook?", | |
| 70 | "Its pages and sections are deleted for good.", | |
| 71 | "Cancel", | |
| 72 | "Empty Recycle Bin", | |
| 73 | ) { | |
| 74 | let change = crate::manage::Structure::EmptyRecycleBin; | |
| 75 | self.commands.push(Command::Structure(library, change)); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | /// Does `request` to the bin's open section on a thread of its own, then shows the page | |
| 80 | /// after the ones gone, or the notebook once the section holds none. | |
| 81 | pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box<dyn Error>> { | |
| 82 | let gone = match &request { | |
| 83 | Request::Restore { copy: true, .. } => Vec::new(), | |
| 84 | Request::Restore { space, .. } => vec![*space], | |
| 85 | Request::Purge(spaces) => spaces.clone(), | |
| 86 | }; | |
| 87 | if matches!(request, Request::Purge(_)) | |
| 88 | && !crate::platform::confirm( | |
| 89 | "Are you sure you want to delete this page for good?", | |
| 90 | "It can't be restored.", | |
| 91 | "Cancel", | |
| 92 | "Delete", | |
| 93 | ) | |
| 94 | { | |
| 95 | return Ok(()); | |
| 96 | } | |
| 97 | let session = self.session.as_ref().ok_or("No section is open")?; | |
| 98 | let replica = Arc::clone(session.section.replica()); | |
| 99 | let library = Arc::clone(&session.library); | |
| 100 | let shown = session.space; | |
| 101 | let at = (session.pages.iter()).position(|(space, ..)| gone.contains(space)); | |
| 102 | let kept = |space: &ExGuid| !gone.contains(space); | |
| 103 | let after = at.and_then(|at| { | |
| 104 | let spaces = session.pages.iter().map(|(space, ..)| *space); | |
| 105 | (spaces.clone().skip(at).find(kept)).or_else(|| spaces.take(at).rev().find(kept)) | |
| 106 | }); | |
| 107 | let (author, proxy) = (self.author.clone(), self.proxy.clone()); | |
| 108 | self.load(move || { | |
| 109 | carry_out(&library, &replica, request, &author)?; | |
| 110 | if let Some(show) = if gone.contains(&shown) { | |
| 111 | after | |
| 112 | } else { | |
| 113 | Some(shown) | |
| 114 | } { | |
| 115 | return Ok(Loaded::Page(show, replica.page(show)?)); | |
| 116 | } | |
| 117 | // A section without pages shows no tab: the notebook shows instead. | |
| 118 | let path = library | |
| 119 | .first_section() | |
| 120 | .ok_or("The notebook has no sections")?; | |
| 121 | let section = library.open(&path, notify(proxy))?; | |
| 122 | let (session, page) = read_session(section, library, path, None)?; | |
| 123 | Ok(Loaded::Section(Box::new(session), page)) | |
| 124 | }); | |
| 125 | Ok(()) | |
| 126 | } | |
| 127 | ||
| 128 | /// The bin's tab row leads with a way back to the notebook and where it is, as | |
| 129 | /// OneNote's "Recycle Bin for" does. | |
| 130 | pub(crate) fn recycle_heading(&mut self, theme: &ui::Theme) { | |
| 131 | let Some(session) = self.session.as_ref().filter(|_| self.in_recycle_bin()) else { | |
| 132 | return; | |
| 133 | }; | |
| 134 | let library = Arc::clone(&session.library); | |
| 135 | let heading = format!("Recycle Bin for \u{201c}{}\u{201d}:", library.name); | |
| 136 | let back = ui::shell::tool_button( | |
| 137 | &mut self.ui, | |
| 138 | "recycle back", | |
| 139 | crate::art::BACK, | |
| 140 | theme.text, | |
| 141 | None, | |
| 142 | ); | |
| 143 | if let Some(node) = self.ui.access(self.ui.id("recycle back")) { | |
| 144 | node.set_label("Back to the Notebook"); | |
| 145 | } | |
| 146 | self.ui.leaf( | |
| 147 | "recycle heading", | |
| 148 | Spec { | |
| 149 | size: [fit(), fill()], | |
| 150 | text: Some(&heading), | |
| 151 | color: Some(theme.text), | |
| 152 | pad: [6.0, 0.0], | |
| 153 | ..Spec::default() | |
| 154 | }, | |
| 155 | ); | |
| 156 | if back.clicked { | |
| 157 | self.toggle_recycle_bin(library); | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | /// Does `request` to the bin's section `replica` holds, in `library`, as `author`: a page | |
| 163 | /// restored goes to its section as one op, keeping its identity, a copy under a new one, and | |
| 164 | /// what leaves the bin goes from it as another. | |
| 165 | fn carry_out( | |
| 166 | library: &Library, | |
| 167 | replica: &notebook::Replica, | |
| 168 | request: Request, | |
| 169 | author: &str, | |
| 170 | ) -> Result<(), Box<dyn Error>> { | |
| 171 | let gone = match request { | |
| 172 | Request::Restore { space, path, copy } => { | |
| 173 | let page = replica.page(space)?; | |
| 174 | let section = library.open(&path, || {})?; | |
| 175 | if copy { | |
| 176 | section.import_page(&page, author)?; | |
| 177 | } else { | |
| 178 | let ops = vec![notebook::session::moved(&page, author)?]; | |
| 179 | section.replica().apply( | |
| 180 | author, | |
| 181 | Edit { | |
| 182 | at: filetime(), | |
| 183 | ops, | |
| 184 | }, | |
| 185 | )?; | |
| 186 | } | |
| 187 | section.close()?; | |
| 188 | (!copy).then_some(vec![space]).unwrap_or_default() | |
| 189 | } | |
| 190 | Request::Purge(spaces) => spaces, | |
| 191 | }; | |
| 192 | if !gone.is_empty() { | |
| 193 | let ops = vec![Op::Section(SectionOp::Delete(gone))]; | |
| 194 | replica.apply( | |
| 195 | author, | |
| 196 | Edit { | |
| 197 | at: filetime(), | |
| 198 | ops, | |
| 199 | }, | |
| 200 | )?; | |
| 201 | } | |
| 202 | Ok(()) | |
| 203 | } | |
| 204 | ||
| 205 | /// The bar above a page of the recycle bin, in OneNote 2010's words but for the menu item | |
| 206 | /// that restores. | |
| 207 | pub fn bar(ui: &mut Ui) { | |
| 208 | ui.open( | |
| 209 | "recycle bar", | |
| 210 | Spec { | |
| 211 | axis: Axis::Y, | |
| 212 | size: [fill(), children()], | |
| 213 | fill: Some(draw::srgb(0xff, 0xee, 0xc2)), | |
| 214 | pad: [12.0, 6.0], | |
| 215 | role: Some(accesskit::Role::Banner), | |
| 216 | ..Spec::default() | |
| 217 | }, | |
| 218 | ); | |
| 219 | for (part, text) in [ | |
| 220 | ( | |
| 221 | "restore", | |
| 222 | "To restore a page or section, right-click it and choose Restore To.", | |
| 223 | ), | |
| 224 | ("purge", "Content here is deleted after 60 days."), | |
| 225 | ] { | |
| 226 | ui.leaf( | |
| 227 | part, | |
| 228 | Spec { | |
| 229 | size: [fill(), fit()], | |
| 230 | text: Some(text), | |
| 231 | overflow: ui::Overflow::Wrap, | |
| 232 | color: Some(draw::srgb(0x20, 0x20, 0x20)), | |
| 233 | ..Spec::default() | |
| 234 | }, | |
| 235 | ); | |
| 236 | } | |
| 237 | ui.close(); | |
| 238 | } | |
| 239 | ||
| 240 | #[cfg(test)] | |
| 241 | mod tests { | |
| 242 | use super::*; | |
| 243 | use notebook::session::{Notebook, stored_pages}; | |
| 244 | use onestore::PageCreation; | |
| 245 | ||
| 246 | const AUTHOR: &str = "Author"; | |
| 247 | const FIRST: &str = "New Section 1.one"; | |
| 248 | ||
| 249 | /// Waits until a section's edits are in its file. | |
| 250 | fn published(library: &Library, path: &str) { | |
| 251 | let section = library.open(path, || {}).unwrap(); | |
| 252 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); | |
| 253 | while section.sync_status().unwrap().queued > 0 { | |
| 254 | assert!( | |
| 255 | std::time::Instant::now() < deadline, | |
| 256 | "{path} never published" | |
| 257 | ); | |
| 258 | std::thread::sleep(std::time::Duration::from_millis(20)); | |
| 259 | } | |
| 260 | section.close().unwrap(); | |
| 261 | } | |
| 262 | ||
| 263 | fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> { | |
| 264 | let pages = stored_pages(&std::fs::read(root.join(path)).unwrap()).unwrap(); | |
| 265 | (pages.into_iter()) | |
| 266 | .map(|page| (page.page.title, page.page.identity)) | |
| 267 | .collect() | |
| 268 | } | |
| 269 | ||
| 270 | /// Pages leave the recycle bin for a section, keeping their identity, or as copies under | |
| 271 | /// new ones, and deleted for good, each as an op; a section leaves for the notebook's | |
| 272 | /// folder, and Empty Recycle Bin takes the rest. `SNOWBOUND_RECYCLE_EXPORT` names a new | |
| 273 | /// directory receiving the notebook `before` and `after`, for cold reopens in OneNote 2010 | |
| 274 | /// (`corpus/recycle-bin-view`). | |
| 275 | #[test] | |
| 276 | fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() { | |
| 277 | let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id())); | |
| 278 | let _ = std::fs::remove_dir_all(&folder); | |
| 279 | std::fs::create_dir_all(&folder).unwrap(); | |
| 280 | let (root, cache) = (folder.join("Recycled"), folder.join("cache")); | |
| 281 | let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap(); | |
| 282 | let mut notebook = | |
| 283 | Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page("Kept")).unwrap(); | |
| 284 | for (name, title) in [ | |
| 285 | ("Back", "Restored page"), | |
| 286 | ("Copied", "Copied page"), | |
| 287 | ("Purged", "Purged page"), | |
| 288 | ] { | |
| 289 | notebook.create_section("", name, &page(title)).unwrap(); | |
| 290 | } | |
| 291 | notebook | |
| 292 | .create_section("", "Restored", &page("In a restored section")) | |
| 293 | .unwrap(); | |
| 294 | notebook | |
| 295 | .create_section("", "Emptied", &page("In an emptied section")) | |
| 296 | .unwrap(); | |
| 297 | for name in ["Back", "Copied", "Purged"] { | |
| 298 | let image = notebook.read_section(&format!("{name}.one")).unwrap(); | |
| 299 | let pages = stored_pages(&image).unwrap(); | |
| 300 | notebook | |
| 301 | .recycle_pages(&[pages[0].page.clone()], AUTHOR) | |
| 302 | .unwrap(); | |
| 303 | } | |
| 304 | for name in ["Back", "Copied", "Purged", "Restored", "Emptied"] { | |
| 305 | notebook.delete(&format!("{name}.one")).unwrap(); | |
| 306 | } | |
| 307 | let export = std::env::var_os("SNOWBOUND_RECYCLE_EXPORT").map(std::path::PathBuf::from); | |
| 308 | if let Some(directory) = &export { | |
| 309 | copy(&root, &directory.join("before")); | |
| 310 | } | |
| 311 | let library = Library::created(root.to_str().unwrap(), notebook, &cache); | |
| 312 | let deleted = format!("{BIN}/OneNote_DeletedPages.one"); | |
| 313 | let binned = titles(&root, &deleted); | |
| 314 | let bin = library.open(&deleted, || {}).unwrap(); | |
| 315 | let space = |title: &str| { | |
| 316 | let pages = bin.replica().pages().unwrap(); | |
| 317 | pages | |
| 318 | .into_iter() | |
| 319 | .find(|(_, listed, _)| listed == title) | |
| 320 | .unwrap() | |
| 321 | .0 | |
| 322 | }; | |
| 323 | let (back, copied, purged) = ( | |
| 324 | space("Restored page"), | |
| 325 | space("Copied page"), | |
| 326 | space("Purged page"), | |
| 327 | ); | |
| 328 | let restore = |space, copy| Request::Restore { | |
| 329 | space, | |
| 330 | path: FIRST.into(), | |
| 331 | copy, | |
| 332 | }; | |
| 333 | carry_out(&library, bin.replica(), restore(back, false), AUTHOR).unwrap(); | |
| 334 | carry_out(&library, bin.replica(), restore(copied, true), AUTHOR).unwrap(); | |
| 335 | carry_out( | |
| 336 | &library, | |
| 337 | bin.replica(), | |
| 338 | Request::Purge(vec![purged]), | |
| 339 | AUTHOR, | |
| 340 | ) | |
| 341 | .unwrap(); | |
| 342 | bin.close().unwrap(); | |
| 343 | published(&library, FIRST); | |
| 344 | published(&library, &deleted); | |
| 345 | let identity = |title: &str| binned.iter().find(|(listed, _)| listed == title).unwrap().1; | |
| 346 | let first = titles(&root, FIRST); | |
| 347 | let listed: Vec<&str> = first.iter().map(|(title, _)| title.as_str()).collect(); | |
| 348 | assert_eq!(listed, ["Kept", "Restored page", "Copied page"]); | |
| 349 | assert_eq!( | |
| 350 | first[1].1, | |
| 351 | identity("Restored page"), | |
| 352 | "a restored page keeps its identity" | |
| 353 | ); | |
| 354 | assert_ne!( | |
| 355 | first[2].1, | |
| 356 | identity("Copied page"), | |
| 357 | "a copy takes a new one" | |
| 358 | ); | |
| 359 | let left: Vec<String> = titles(&root, &deleted) | |
| 360 | .into_iter() | |
| 361 | .map(|(title, _)| title) | |
| 362 | .collect(); | |
| 363 | assert_eq!(left, ["Copied page"]); | |
| 364 | ||
| 365 | let mut notebook = library.reopen().unwrap(); | |
| 366 | notebook | |
| 367 | .move_entry(&format!("{BIN}/Restored.one"), "") | |
| 368 | .unwrap(); | |
| 369 | notebook.empty_recycle_bin().unwrap(); | |
| 370 | let sections: Vec<&str> = (notebook.catalog().sections.iter()) | |
| 371 | .map(|section| section.path.as_str()) | |
| 372 | .collect(); | |
| 373 | assert_eq!(sections, [FIRST, "Restored.one"]); | |
| 374 | assert!(titles(&root, &deleted).is_empty()); | |
| 375 | assert!(!root.join(BIN).join("Emptied.one").exists()); | |
| 376 | if let Some(directory) = &export { | |
| 377 | copy(&root, &directory.join("after")); | |
| 378 | } | |
| 379 | std::fs::remove_dir_all(&folder).unwrap(); | |
| 380 | } | |
| 381 | ||
| 382 | fn copy(from: &std::path::Path, to: &std::path::Path) { | |
| 383 | std::fs::create_dir_all(to).unwrap(); | |
| 384 | for entry in std::fs::read_dir(from).unwrap().flatten() { | |
| 385 | let target = to.join(entry.file_name()); | |
| 386 | if entry.file_type().unwrap().is_dir() { | |
| 387 | copy(&entry.path(), &target); | |
| 388 | } else { | |
| 389 | std::fs::copy(entry.path(), target).unwrap(); | |
| 390 | } | |
| 391 | } | |
| 392 | } | |
| 393 | } |
crates/snowbound/src/rename.rs+2-4| ... | ... | @@ -81,8 +81,7 @@ impl State { |
| 81 | 81 | let name = match &target { |
| 82 | 82 | Target::Entry { path, in_tab, .. } => { |
| 83 | 83 | self.sidebar |= !in_tab; |
| 84 | let name = path.rsplit('/').next().unwrap_or_default(); | |
| 85 | name.strip_suffix(".one").unwrap_or(name).to_owned() | |
| 84 | crate::library::entry_name(path).to_owned() | |
| 86 | 85 | } |
| 87 | 86 | Target::Page(space) => self |
| 88 | 87 | .session |
| ... | ... | @@ -107,8 +106,7 @@ impl State { |
| 107 | 106 | } |
| 108 | 107 | match target { |
| 109 | 108 | Target::Entry { library, path, .. } => { |
| 110 | let old = path.rsplit('/').next().unwrap_or_default(); | |
| 111 | if name != old.strip_suffix(".one").unwrap_or(old) { | |
| 109 | if name != crate::library::entry_name(&path) { | |
| 112 | 110 | self.commands.push(Command::Structure( |
| 113 | 111 | library, |
| 114 | 112 | Structure::Rename { path, name }, |
crates/snowbound/src/save_as.rs created+271| ... | ... | @@ -0,0 +1,271 @@ |
| 1 | //! File, Save As, as OneNote 2010's: the page, its section or its notebook, as a OneNote | |
| 2 | //! section (`.one`), a package (`.onepkg`) or a PDF. A page or section saved as a section is | |
| 3 | //! a copy outside any notebook; a notebook's package holds its sections as they stand here, | |
| 4 | //! the open one's queued edits included (`notebook::package`). | |
| 5 | ||
| 6 | use crate::{Library, State, UserEvent, platform, print}; | |
| 7 | use accesskit::Role; | |
| 8 | use notebook::package; | |
| 9 | use std::{error::Error, sync::Arc}; | |
| 10 | use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; | |
| 11 | use winit::keyboard::NamedKey; | |
| 12 | ||
| 13 | /// What Save As saves. | |
| 14 | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 15 | pub enum Scope { | |
| 16 | Page, | |
| 17 | Section, | |
| 18 | Notebook, | |
| 19 | } | |
| 20 | ||
| 21 | const SCOPES: [(Scope, &str); 3] = [ | |
| 22 | (Scope::Page, "Page"), | |
| 23 | (Scope::Section, "Section"), | |
| 24 | (Scope::Notebook, "Notebook"), | |
| 25 | ]; | |
| 26 | ||
| 27 | /// OneNote 2010's formats for each scope, as Save As lists them. | |
| 28 | fn formats(scope: Scope) -> [(&'static str, &'static str); 2] { | |
| 29 | match scope { | |
| 30 | Scope::Notebook => [ | |
| 31 | ("OneNote Package (*.onepkg)", "onepkg"), | |
| 32 | ("PDF (*.pdf)", "pdf"), | |
| 33 | ], | |
| 34 | _ => [ | |
| 35 | ("OneNote 2010 Section (*.one)", "one"), | |
| 36 | ("PDF (*.pdf)", "pdf"), | |
| 37 | ], | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /// The dialog's choices while it is open: the section a scope of Section saves, by catalog | |
| 42 | /// path, and the place in `formats` chosen. | |
| 43 | pub struct Dialog { | |
| 44 | library: Arc<Library>, | |
| 45 | section: Option<String>, | |
| 46 | scope: Scope, | |
| 47 | format: usize, | |
| 48 | } | |
| 49 | ||
| 50 | fn id() -> Id { | |
| 51 | Id::ROOT.child("save-as") | |
| 52 | } | |
| 53 | ||
| 54 | impl State { | |
| 55 | /// Opens Save As on `scope`: the open page or section, or `section` of `library` when | |
| 56 | /// given, or the notebook. | |
| 57 | pub(crate) fn open_save_as( | |
| 58 | &mut self, | |
| 59 | library: Arc<Library>, | |
| 60 | section: Option<String>, | |
| 61 | scope: Scope, | |
| 62 | ) { | |
| 63 | let section = section.or_else(|| { | |
| 64 | let session = self.session.as_ref()?; | |
| 65 | Some(session.tabs[session.tab].path.clone()) | |
| 66 | }); | |
| 67 | self.save_as = Some(Dialog { | |
| 68 | library, | |
| 69 | section, | |
| 70 | scope, | |
| 71 | format: 0, | |
| 72 | }); | |
| 73 | self.ui.open_popup(id()); | |
| 74 | } | |
| 75 | ||
| 76 | /// Builds Save As while it is open; Save As… asks where, then writes on a thread. | |
| 77 | pub(crate) fn save_as_dialog(&mut self) { | |
| 78 | let Some(dialog) = &mut self.save_as else { | |
| 79 | return; | |
| 80 | }; | |
| 81 | let ui = &mut self.ui; | |
| 82 | if !ui.popup_open(id()) { | |
| 83 | self.save_as = None; | |
| 84 | return; | |
| 85 | } | |
| 86 | let theme = ui.theme.clone(); | |
| 87 | let row = theme.font_size * 2.0; | |
| 88 | let entered = ui::popup::navigation(ui, &[], &[NamedKey::Enter]).contains(&NamedKey::Enter); | |
| 89 | ui.open_as( | |
| 90 | id(), | |
| 91 | Spec { | |
| 92 | axis: Axis::Y, | |
| 93 | size: [px(360.0), children()], | |
| 94 | fill: Some(theme.popup), | |
| 95 | border: Some(theme.chip), | |
| 96 | shadow: Some(theme.shadow), | |
| 97 | radius: 8.0, | |
| 98 | pad: [16.0, 12.0], | |
| 99 | gap: 6.0, | |
| 100 | anchor: Some(Anchor::Dialog), | |
| 101 | role: Some(Role::Dialog), | |
| 102 | ..Spec::default() | |
| 103 | }, | |
| 104 | ); | |
| 105 | if let Some(node) = ui.access(id()) { | |
| 106 | node.set_label("Save As"); | |
| 107 | } | |
| 108 | ui.leaf( | |
| 109 | "title", | |
| 110 | Spec { | |
| 111 | size: [fill(), px(row)], | |
| 112 | text: Some("Save As"), | |
| 113 | bold: true, | |
| 114 | role: Some(Role::Heading), | |
| 115 | ..Spec::default() | |
| 116 | }, | |
| 117 | ); | |
| 118 | // A page or section is only there to save with one open, or picked. | |
| 119 | let offered: Vec<(Scope, &str)> = (SCOPES.iter().copied()) | |
| 120 | .filter(|(scope, _)| match scope { | |
| 121 | Scope::Page => self.session.is_some(), | |
| 122 | Scope::Section => dialog.section.is_some(), | |
| 123 | Scope::Notebook => dialog.library.catalog().is_some(), | |
| 124 | }) | |
| 125 | .collect(); | |
| 126 | let current = offered.iter().position(|(scope, _)| *scope == dialog.scope); | |
| 127 | let names: Vec<&str> = offered.iter().map(|(_, name)| *name).collect(); | |
| 128 | if let Some(at) = choose(ui, "Save current", &names, current.unwrap_or(0)) { | |
| 129 | dialog.scope = offered[at].0; | |
| 130 | dialog.format = 0; | |
| 131 | } | |
| 132 | let names = formats(dialog.scope).map(|(name, _)| name); | |
| 133 | if let Some(at) = choose(ui, "Format", &names, dialog.format) { | |
| 134 | dialog.format = at; | |
| 135 | } | |
| 136 | crate::buttons(ui); | |
| 137 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | |
| 138 | let go = ui::button(ui, "go", "Save As…").clicked || entered; | |
| 139 | ui.close(); | |
| 140 | ui.close(); | |
| 141 | if !go && !cancel { | |
| 142 | return; | |
| 143 | } | |
| 144 | let dialog = self.save_as.take().expect("The dialog is open"); | |
| 145 | self.ui.close_popup(id()); | |
| 146 | if go && let Err(error) = self.save(dialog) { | |
| 147 | platform::alert("Couldn't save", &error.to_string()); | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | /// Asks where to save what `dialog` chose, then writes it on a thread of its own; a PDF | |
| 152 | /// goes through Export as PDF's settings. | |
| 153 | fn save(&mut self, dialog: Dialog) -> Result<(), Box<dyn Error>> { | |
| 154 | let (_, extension) = formats(dialog.scope)[dialog.format]; | |
| 155 | if extension == "pdf" { | |
| 156 | self.export_pdf(match dialog.scope { | |
| 157 | Scope::Page => print::Scope::Page, | |
| 158 | Scope::Section => print::Scope::Section, | |
| 159 | Scope::Notebook => print::Scope::Notebook, | |
| 160 | }); | |
| 161 | return Ok(()); | |
| 162 | } | |
| 163 | self.persist()?; | |
| 164 | let library = dialog.library; | |
| 165 | let open = (self.session.as_ref()) | |
| 166 | .filter(|session| Arc::ptr_eq(&session.library, &library)) | |
| 167 | .map(|session| { | |
| 168 | let path = session.tabs[session.tab].path.clone(); | |
| 169 | (path, Arc::clone(session.section.replica()), session.space) | |
| 170 | }); | |
| 171 | let section = dialog.section.unwrap_or_default(); | |
| 172 | let title = match dialog.scope { | |
| 173 | Scope::Page => self.view.editor.page()?.title, | |
| 174 | Scope::Section => crate::library::section_name(&section, &None), | |
| 175 | Scope::Notebook => library.name.clone(), | |
| 176 | }; | |
| 177 | let name = format!("{}.{extension}", print::file_name(&title)); | |
| 178 | let Some(mut path) = platform::pick_new("Save As", &name, "Save", None) else { | |
| 179 | return Ok(()); | |
| 180 | }; | |
| 181 | if path.extension().is_none() { | |
| 182 | path.set_extension(extension); | |
| 183 | } | |
| 184 | let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone()); | |
| 185 | let color = (library | |
| 186 | .tabs(crate::menus::folder(&section).as_str()) | |
| 187 | .into_iter()) | |
| 188 | .find(|tab| tab.path == section) | |
| 189 | .and_then(|tab| tab.color); | |
| 190 | std::thread::spawn(move || { | |
| 191 | let written = (|| -> Result<(), Box<dyn Error>> { | |
| 192 | let image = |path: &str| { | |
| 193 | let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; | |
| 194 | replica.snapshot().ok() | |
| 195 | }; | |
| 196 | let bytes = match scope { | |
| 197 | Scope::Page => { | |
| 198 | let (_, replica, space) = open.as_ref().ok_or("No page is open")?; | |
| 199 | let page = replica.page(*space)?; | |
| 200 | let file = file_name(&path); | |
| 201 | package::page_section(&page, &file, color, &author)? | |
| 202 | } | |
| 203 | Scope::Section => { | |
| 204 | let stored = match image(&section) { | |
| 205 | Some(image) => image, | |
| 206 | None => library.reopen()?.read_section(&section)?, | |
| 207 | }; | |
| 208 | package::section_copy(stored)? | |
| 209 | } | |
| 210 | Scope::Notebook => { | |
| 211 | let notebook = library.reopen()?; | |
| 212 | package::pack(package::notebook_files(&notebook, image)?)? | |
| 213 | } | |
| 214 | }; | |
| 215 | std::fs::write(&path, bytes)?; | |
| 216 | Ok(()) | |
| 217 | })(); | |
| 218 | let written = written.map_err(|error| error.to_string()); | |
| 219 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { | |
| 220 | written.map_err(|error| { | |
| 221 | platform::alert("Couldn't save", &error); | |
| 222 | error.into() | |
| 223 | }) | |
| 224 | }))); | |
| 225 | }); | |
| 226 | Ok(()) | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | fn file_name(path: &std::path::Path) -> String { | |
| 231 | path.file_name() | |
| 232 | .map(|name| name.to_string_lossy().into_owned()) | |
| 233 | .unwrap_or_default() | |
| 234 | } | |
| 235 | ||
| 236 | /// A labelled combo listing `names`, choosing among them. | |
| 237 | fn choose(ui: &mut ui::Ui, label: &str, names: &[&str], current: usize) -> Option<usize> { | |
| 238 | let row = ui.theme.font_size * 2.0; | |
| 239 | ui.open( | |
| 240 | label, | |
| 241 | Spec { | |
| 242 | size: [fill(), px(row)], | |
| 243 | gap: 8.0, | |
| 244 | ..Spec::default() | |
| 245 | }, | |
| 246 | ); | |
| 247 | ui.leaf( | |
| 248 | "label", | |
| 249 | Spec { | |
| 250 | size: [px(110.0), px(row)], | |
| 251 | text: Some(label), | |
| 252 | ..Spec::default() | |
| 253 | }, | |
| 254 | ); | |
| 255 | let menu = id().child(label); | |
| 256 | let combo = ui.id(label); | |
| 257 | let shown = names.get(current).copied().unwrap_or_default(); | |
| 258 | ui::shell::combo(ui, label, label, shown, 210.0, menu, true); | |
| 259 | let items: Vec<Item> = (names.iter().enumerate()) | |
| 260 | .map(|(at, text)| Item { | |
| 261 | text, | |
| 262 | checked: Some(at == current), | |
| 263 | current: at == current, | |
| 264 | ..Item::default() | |
| 265 | }) | |
| 266 | .collect(); | |
| 267 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | |
| 268 | let chosen = ui::popup::menu(ui, menu, anchor, &items, None); | |
| 269 | ui.close(); | |
| 270 | chosen | |
| 271 | } |
crates/snowbound/src/search.rs+30-42| ... | ... | @@ -272,29 +272,21 @@ fn run( |
| 272 | 272 | fn sections(library: &Library) -> Vec<String> { |
| 273 | 273 | match &library.notebook { |
| 274 | 274 | Ok(Some(notebook)) => { |
| 275 | let mut paths = Vec::new(); | |
| 276 | let mut folders = vec![notebook.catalog()]; | |
| 277 | while let Some(folder) = folders.pop() { | |
| 278 | paths.extend( | |
| 279 | folder | |
| 280 | .sections | |
| 281 | .iter() | |
| 282 | .filter(|section| { | |
| 283 | matches!( | |
| 284 | section.state, | |
| 285 | notebook::discover::SectionState::Readable { .. } | |
| 286 | ) | |
| 287 | }) | |
| 288 | .map(|section| section.path.clone()), | |
| 289 | ); | |
| 290 | folders.extend( | |
| 291 | folder | |
| 292 | .groups | |
| 293 | .iter() | |
| 294 | .filter(|group| !crate::library::recycle_bin(&group.path)), | |
| 295 | ); | |
| 296 | } | |
| 297 | paths | |
| 275 | let enter = | |
| 276 | |group: &notebook::discover::Folder| !crate::library::recycle_bin(&group.path); | |
| 277 | crate::library::folders(notebook.catalog(), enter) | |
| 278 | .into_iter() | |
| 279 | .flat_map(|folder| &folder.sections) | |
| 280 | .filter(|section| match section.state { | |
| 281 | notebook::discover::SectionState::Readable { .. } => true, | |
| 282 | // OneNote searches a protected section only while it is unlocked. | |
| 283 | notebook::discover::SectionState::Locked => { | |
| 284 | library.unlocked(&section.path).is_some() | |
| 285 | } | |
| 286 | _ => false, | |
| 287 | }) | |
| 288 | .map(|section| section.path.clone()) | |
| 289 | .collect() | |
| 298 | 290 | } |
| 299 | 291 | Ok(None) => vec![library.location.clone()], |
| 300 | 292 | Err(_) => Vec::new(), |
| ... | ... | @@ -339,7 +331,10 @@ fn sync( |
| 339 | 331 | Some(notebook) => notebook.read_section(&path)?, |
| 340 | 332 | None => onestore::read_file(&file)?, |
| 341 | 333 | }; |
| 342 | let stored = notebook::session::stored_pages(&bytes)?; | |
| 334 | let stored = match library.unlocked(&path) { | |
| 335 | Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?, | |
| 336 | None => notebook::session::stored_pages(&bytes)?, | |
| 337 | }; | |
| 343 | 338 | let modified = |space: ExGuid| { |
| 344 | 339 | stored |
| 345 | 340 | .iter() |
| ... | ... | @@ -485,12 +480,10 @@ impl State { |
| 485 | 480 | /// Brings the index to the open notebooks and section when they changed, and when |
| 486 | 481 | /// `always` or sections `changed` names, by key, to the section files changed since. |
| 487 | 482 | pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) { |
| 488 | let open = self.session.as_ref().map(|session| { | |
| 489 | ( | |
| 490 | session.library.key(&session.tabs[session.tab].path), | |
| 491 | Arc::downgrade(session.section.replica()), | |
| 492 | ) | |
| 493 | }); | |
| 483 | let open = self | |
| 484 | .session | |
| 485 | .as_ref() | |
| 486 | .map(|session| (session.key(), Arc::downgrade(session.section.replica()))); | |
| 494 | 487 | let identity: Vec<usize> = self |
| 495 | 488 | .notebooks |
| 496 | 489 | .iter() |
| ... | ... | @@ -511,11 +504,8 @@ impl State { |
| 511 | 504 | /// Tells the index an edit changed page `space` of the open section. |
| 512 | 505 | pub(crate) fn edited(&self, spaces: Vec<ExGuid>) { |
| 513 | 506 | if let Some(session) = &self.session { |
| 514 | self.search.changed( | |
| 515 | session.library.key(&session.tabs[session.tab].path), | |
| 516 | session.section.replica(), | |
| 517 | spaces, | |
| 518 | ); | |
| 507 | self.search | |
| 508 | .changed(session.key(), session.section.replica(), spaces); | |
| 519 | 509 | } |
| 520 | 510 | } |
| 521 | 511 | |
| ... | ... | @@ -544,7 +534,7 @@ impl State { |
| 544 | 534 | let open = self |
| 545 | 535 | .session |
| 546 | 536 | .as_ref() |
| 547 | .map(|session| session.library.key(&session.tabs[session.tab].path)) | |
| 537 | .map(crate::Session::key) | |
| 548 | 538 | .unwrap_or_default(); |
| 549 | 539 | let wanted = (query.clone(), self.search.scope, version, open); |
| 550 | 540 | if self.search.found_for.as_ref() == Some(&wanted) { |
| ... | ... | @@ -673,12 +663,10 @@ impl State { |
| 673 | 663 | ) -> Result<(), Box<dyn Error>> { |
| 674 | 664 | let (location, path) = section.split_once('\n').unwrap_or_default(); |
| 675 | 665 | self.search.reveal = Some((space, paragraph)); |
| 676 | let open = self.session.as_ref().map(|session| { | |
| 677 | ( | |
| 678 | session.library.key(&session.tabs[session.tab].path), | |
| 679 | session.space, | |
| 680 | ) | |
| 681 | }); | |
| 666 | let open = self | |
| 667 | .session | |
| 668 | .as_ref() | |
| 669 | .map(|session| (session.key(), session.space)); | |
| 682 | 670 | match open { |
| 683 | 671 | Some((key, shown)) if key == section && shown == space => self.refind(true)?, |
| 684 | 672 | Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)), |
crates/snowbound/src/settings.rs+26| ... | ... | @@ -48,6 +48,27 @@ pub struct Settings { |
| 48 | 48 | pub keys: std::collections::BTreeMap<String, Vec<String>>, |
| 49 | 49 | /// Pages shown lately, latest first, which the palette lists first. |
| 50 | 50 | pub recent: Vec<crate::navigation::Place>, |
| 51 | pub passwords: Passwords, | |
| 52 | } | |
| 53 | ||
| 54 | /// Options' Passwords, as OneNote 2010's Advanced page keeps them. | |
| 55 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] | |
| 56 | #[serde(default)] | |
| 57 | pub struct Passwords { | |
| 58 | /// Minutes a protected section stays unlocked without being worked in; none keeps it. | |
| 59 | pub lock_after: Option<u32>, | |
| 60 | /// Locks a protected section as soon as another is shown. | |
| 61 | pub lock_on_leave: bool, | |
| 62 | } | |
| 63 | ||
| 64 | impl Default for Passwords { | |
| 65 | /// OneNote 2010's: locked after 10 minutes. | |
| 66 | fn default() -> Self { | |
| 67 | Self { | |
| 68 | lock_after: Some(10), | |
| 69 | lock_on_leave: false, | |
| 70 | } | |
| 71 | } | |
| 51 | 72 | } |
| 52 | 73 | |
| 53 | 74 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | ... | @@ -166,6 +187,7 @@ impl crate::State { |
| 166 | 187 | servers: self.servers.clone(), |
| 167 | 188 | keys: crate::commands::Keymap::current().saved(), |
| 168 | 189 | recent: self.trail.recent.clone(), |
| 190 | passwords: self.passwords, | |
| 169 | 191 | }; |
| 170 | 192 | if let Err(error) = settings.save(path) { |
| 171 | 193 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | ... | @@ -244,6 +266,10 @@ mod tests { |
| 244 | 266 | n: 1, |
| 245 | 267 | }, |
| 246 | 268 | }], |
| 269 | passwords: Passwords { | |
| 270 | lock_after: None, | |
| 271 | lock_on_leave: true, | |
| 272 | }, | |
| 247 | 273 | }; |
| 248 | 274 | settings.save(&path).unwrap(); |
| 249 | 275 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+38-20| ... | ... | @@ -28,7 +28,7 @@ pub enum Action { |
| 28 | 28 | notebook: usize, |
| 29 | 29 | path: String, |
| 30 | 30 | }, |
| 31 | /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. | |
| 31 | /// Folds or unfolds a notebook's or section group's rows, by `Library::key`. | |
| 32 | 32 | Fold(String), |
| 33 | 33 | NewNotebook, |
| 34 | 34 | NewICloudNotebook, |
| ... | ... | @@ -45,11 +45,6 @@ pub enum Action { |
| 45 | 45 | Unavailable(String, String), |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | /// Names a notebook's or group's rows for folding them. | |
| 49 | pub fn fold_key(library: &Library, path: &str) -> String { | |
| 50 | format!("{}\n{path}", library.location) | |
| 51 | } | |
| 52 | ||
| 53 | 48 | /// What the tree's rows read and what they were asked. |
| 54 | 49 | pub struct Tree<'a> { |
| 55 | 50 | theme: &'a Theme, |
| ... | ... | @@ -68,6 +63,8 @@ pub struct Tree<'a> { |
| 68 | 63 | ghost: Option<Ghost>, |
| 69 | 64 | /// Where the gap the dragged row would land in stands. |
| 70 | 65 | gap: Option<[f32; 4]>, |
| 66 | /// The notebooks, groups and sections holding unread pages, by `Library::key`. | |
| 67 | unread: HashSet<String>, | |
| 71 | 68 | } |
| 72 | 69 | |
| 73 | 70 | /// Where a dragged row would land: before a row of a folder or at its end, or into a group. |
| ... | ... | @@ -85,6 +82,7 @@ struct Ghost { |
| 85 | 82 | depth: u32, |
| 86 | 83 | dim: bool, |
| 87 | 84 | selected: Option<[f32; 4]>, |
| 85 | bold: bool, | |
| 88 | 86 | } |
| 89 | 87 | |
| 90 | 88 | impl<'a> Tree<'a> { |
| ... | ... | @@ -105,6 +103,7 @@ impl<'a> Tree<'a> { |
| 105 | 103 | lifted: None, |
| 106 | 104 | ghost: None, |
| 107 | 105 | gap: None, |
| 106 | unread: HashSet::new(), | |
| 108 | 107 | } |
| 109 | 108 | } |
| 110 | 109 | } |
| ... | ... | @@ -137,14 +136,13 @@ pub fn header() -> Id { |
| 137 | 136 | Id::ROOT.child("sidebar header") |
| 138 | 137 | } |
| 139 | 138 | |
| 140 | /// The sidebar's header row, `header` tall and dragging the window when `drags`, then | |
| 139 | /// The sidebar's header row, as tall as the tab row and dragging the window when `drags`, then | |
| 141 | 140 | /// with `rows` the tree of `notebooks` with the open section marked. The notebook button |
| 142 | 141 | /// floats over the header's end at the window's edge, the `right` one or the left. |
| 143 | 142 | fn sidebar( |
| 144 | 143 | ui: &mut Ui, |
| 145 | 144 | tree: &mut Tree, |
| 146 | 145 | notebooks: &[Arc<Library>], |
| 147 | header: f32, | |
| 148 | 146 | drags: bool, |
| 149 | 147 | rows: bool, |
| 150 | 148 | right: bool, |
| ... | ... | @@ -158,8 +156,8 @@ fn sidebar( |
| 158 | 156 | } else { |
| 159 | 157 | Flags::default() |
| 160 | 158 | }, |
| 161 | size: [fill(), px(header)], | |
| 162 | pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0 + DROP], | |
| 159 | size: [fill(), px(RAIL)], | |
| 160 | pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP], | |
| 163 | 161 | gap: 6.0, |
| 164 | 162 | ..Spec::default() |
| 165 | 163 | }, |
| ... | ... | @@ -204,7 +202,7 @@ fn sidebar( |
| 204 | 202 | }, |
| 205 | 203 | ); |
| 206 | 204 | for (index, library) in notebooks.iter().enumerate() { |
| 207 | let key = fold_key(library, ""); | |
| 205 | let key = library.key(""); | |
| 208 | 206 | let unfolded = !folded.contains(&key); |
| 209 | 207 | let (row, fold) = tree_row( |
| 210 | 208 | ui, |
| ... | ... | @@ -222,6 +220,7 @@ fn sidebar( |
| 222 | 220 | fold: Some(unfolded), |
| 223 | 221 | selected: None, |
| 224 | 222 | renamed: false, |
| 223 | bold: tree.unread.contains(&key), | |
| 225 | 224 | }, |
| 226 | 225 | ); |
| 227 | 226 | let unsigned = library.notebook.is_err() |
| ... | ... | @@ -281,6 +280,7 @@ fn sidebar( |
| 281 | 280 | fold: None, |
| 282 | 281 | selected: None, |
| 283 | 282 | renamed: false, |
| 283 | bold: false, | |
| 284 | 284 | }, |
| 285 | 285 | ); |
| 286 | 286 | if row.clicked { |
| ... | ... | @@ -315,6 +315,12 @@ fn folder( |
| 315 | 315 | *color, |
| 316 | 316 | true, |
| 317 | 317 | ), |
| 318 | // Opens on its locked page, which unlocks it. | |
| 319 | SectionState::Locked => ( | |
| 320 | crate::library::section_name(&section.path, &None), | |
| 321 | None, | |
| 322 | true, | |
| 323 | ), | |
| 318 | 324 | _ => ( |
| 319 | 325 | crate::library::section_name(&section.path, &None), |
| 320 | 326 | None, |
| ... | ... | @@ -332,6 +338,7 @@ fn folder( |
| 332 | 338 | fold: None, |
| 333 | 339 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), |
| 334 | 340 | renamed: renaming, |
| 341 | bold: tree.unread.contains(&library.key(&section.path)), | |
| 335 | 342 | }; |
| 336 | 343 | if lifts(tree, notebook, &section.path, &row) { |
| 337 | 344 | continue; |
| ... | ... | @@ -357,7 +364,7 @@ fn folder( |
| 357 | 364 | if crate::library::recycle_bin(&group.path) { |
| 358 | 365 | continue; |
| 359 | 366 | } |
| 360 | let key = fold_key(library, &group.path); | |
| 367 | let key = library.key(&group.path); | |
| 361 | 368 | let unfolded = !tree.folded.contains(&key); |
| 362 | 369 | let name = group.path.rsplit('/').next().unwrap_or_default(); |
| 363 | 370 | gap(ui, tree, &folder.path, Some(&group.path)); |
| ... | ... | @@ -371,6 +378,7 @@ fn folder( |
| 371 | 378 | fold: Some(unfolded), |
| 372 | 379 | selected: None, |
| 373 | 380 | renamed: renaming, |
| 381 | bold: tree.unread.contains(&key), | |
| 374 | 382 | }; |
| 375 | 383 | // A dragged group's rows fold away beneath it. |
| 376 | 384 | let lifted = lifts(tree, notebook, &group.path, &row); |
| ... | ... | @@ -425,6 +433,7 @@ fn folder( |
| 425 | 433 | fold: None, |
| 426 | 434 | selected: None, |
| 427 | 435 | renamed: false, |
| 436 | bold: false, | |
| 428 | 437 | }, |
| 429 | 438 | ); |
| 430 | 439 | if row.clicked { |
| ... | ... | @@ -499,6 +508,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { |
| 499 | 508 | depth: row.depth, |
| 500 | 509 | dim: row.dim, |
| 501 | 510 | selected: row.selected, |
| 511 | bold: row.bold, | |
| 502 | 512 | }); |
| 503 | 513 | } |
| 504 | 514 | lifted |
| ... | ... | @@ -594,6 +604,8 @@ struct Row<'a> { |
| 594 | 604 | selected: Option<[f32; 4]>, |
| 595 | 605 | /// The row is being renamed: its label is the tree's rename field. |
| 596 | 606 | renamed: bool, |
| 607 | /// It holds unread pages, which OneNote sets bold. | |
| 608 | bold: bool, | |
| 597 | 609 | } |
| 598 | 610 | |
| 599 | 611 | /// One row of the tree: its signal, and whether its fold arrow was clicked. |
| ... | ... | @@ -653,6 +665,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { |
| 653 | 665 | Spec { |
| 654 | 666 | size: [fill(), px(ROW)], |
| 655 | 667 | text: Some(row.label), |
| 668 | bold: row.bold, | |
| 656 | 669 | color: Some(color), |
| 657 | 670 | ..Spec::default() |
| 658 | 671 | }, |
| ... | ... | @@ -729,15 +742,22 @@ impl crate::State { |
| 729 | 742 | }, |
| 730 | 743 | ); |
| 731 | 744 | crate::name(&mut self.ui, rows_id, "Notebooks"); |
| 732 | let open = self.session.as_ref().and_then(|session| { | |
| 745 | let shown = match (&self.session, &self.locked) { | |
| 746 | (Some(session), _) => Some((&session.library, session.tabs[session.tab].path.as_str())), | |
| 747 | (None, Some(locked)) => Some((&locked.library, locked.path.as_str())), | |
| 748 | (None, None) => None, | |
| 749 | }; | |
| 750 | let open = shown.and_then(|(shown, path)| { | |
| 733 | 751 | let index = self |
| 734 | 752 | .notebooks |
| 735 | 753 | .iter() |
| 736 | .position(|library| Arc::ptr_eq(library, &session.library))?; | |
| 737 | Some((index, session.tabs[session.tab].path.as_str())) | |
| 754 | .position(|library| Arc::ptr_eq(library, shown))?; | |
| 755 | Some((index, path)) | |
| 738 | 756 | }); |
| 739 | 757 | let drags = self.chrome_drags(); |
| 758 | let unread = self.unread_keys(); | |
| 740 | 759 | let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); |
| 760 | tree.unread = unread; | |
| 741 | 761 | tree.lifted = self |
| 742 | 762 | .drag |
| 743 | 763 | .as_ref() |
| ... | ... | @@ -750,7 +770,6 @@ impl crate::State { |
| 750 | 770 | &mut self.ui, |
| 751 | 771 | &mut tree, |
| 752 | 772 | &self.notebooks, |
| 753 | crate::TAB_ROW, | |
| 754 | 773 | drags, |
| 755 | 774 | width > 0.5, |
| 756 | 775 | self.navigation_bar_right, |
| ... | ... | @@ -800,9 +819,6 @@ impl crate::State { |
| 800 | 819 | } |
| 801 | 820 | } |
| 802 | 821 | |
| 803 | /// The sidebar's button in its square at the window's edge, and the room the section | |
| 804 | /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar | |
| 805 | /// eases open and shut. | |
| 806 | 822 | /// The notebook button, floating at the body's corner over the section tabs' row, `height` |
| 807 | 823 | /// tall as it eases, or over the sidebar's header while that is open. |
| 808 | 824 | pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { |
| ... | ... | @@ -903,6 +919,7 @@ impl crate::State { |
| 903 | 919 | fold: None, |
| 904 | 920 | selected: ghost.selected, |
| 905 | 921 | renamed: false, |
| 922 | bold: ghost.bold, | |
| 906 | 923 | }, |
| 907 | 924 | ); |
| 908 | 925 | self.ui.close(); |
| ... | ... | @@ -1049,9 +1066,10 @@ impl crate::State { |
| 1049 | 1066 | self.commands |
| 1050 | 1067 | .push(crate::Command::OpenSection(library, path)); |
| 1051 | 1068 | } |
| 1069 | crate::library::Located::Package(package) => self.open_unpack(&package), | |
| 1052 | 1070 | crate::library::Located::Nothing => crate::platform::alert( |
| 1053 | 1071 | "Couldn't open", |
| 1054 | "Choose a notebook folder, its Open Notebook file, or a section file.", | |
| 1072 | "Choose a notebook folder, its Open Notebook file, a section file or a package.", | |
| 1055 | 1073 | ), |
| 1056 | 1074 | } |
| 1057 | 1075 | } |
crates/snowbound/src/spell_linux.rs+1-6| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | //! Spelling through Enchant, loaded at run time with whichever dictionaries its providers |
| 2 | 2 | //! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked. |
| 3 | 3 | |
| 4 | use crate::platform::symbol; | |
| 4 | 5 | use canvas::spelling::{Dictionary, pick}; |
| 5 | 6 | use std::collections::HashMap; |
| 6 | 7 | use std::ffi::{CStr, CString, c_char, c_int, c_void}; |
| ... | ... | @@ -98,12 +99,6 @@ impl Dictionary for Checker { |
| 98 | 99 | } |
| 99 | 100 | } |
| 100 | 101 | |
| 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 | 102 | unsafe extern "C" fn describe( |
| 108 | 103 | tag: *const c_char, |
| 109 | 104 | _: *const c_char, |
crates/snowbound/src/surface_gl.rs+11-11| ... | ... | @@ -92,36 +92,36 @@ impl Surface { |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | /// The window's next frame, or none to skip this one. |
| 95 | pub fn frame(&mut self, _: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> { | |
| 95 | pub fn frame(&mut self, renderer: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> { | |
| 96 | 96 | if self.size.contains(&0) { |
| 97 | 97 | return Ok(None); |
| 98 | 98 | } |
| 99 | 99 | let target = match self.target.take() { |
| 100 | 100 | Some(target) if target.size() == self.size => target, |
| 101 | _ => Target::new(self.size)?, | |
| 101 | _ => renderer.target(self.size)?, | |
| 102 | 102 | }; |
| 103 | 103 | Ok(Some(Frame { target })) |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | 106 | pub fn present(&mut self, renderer: &Renderer, frame: Frame) { |
| 107 | if self.translucent { | |
| 108 | renderer.present_translucent(&frame.target); | |
| 109 | } else { | |
| 110 | frame.target.present(); | |
| 111 | } | |
| 107 | renderer.present(&frame.target, self.translucent); | |
| 112 | 108 | let _: () = unsafe { msg_send![&self.context, flushBuffer] }; |
| 113 | 109 | self.target = Some(frame.target); |
| 114 | 110 | } |
| 115 | 111 | |
| 116 | 112 | /// A target the size of the window's frames that `read` reads back. |
| 117 | pub fn offscreen(&self, _: &Renderer) -> Result<Offscreen, Box<dyn Error>> { | |
| 113 | pub fn offscreen(&self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> { | |
| 118 | 114 | Ok(Frame { |
| 119 | target: Target::new(self.size)?, | |
| 115 | target: renderer.target(self.size)?, | |
| 120 | 116 | }) |
| 121 | 117 | } |
| 122 | 118 | |
| 123 | 119 | /// The offscreen target's sRGB RGBA rows, top first. |
| 124 | pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 125 | Ok(offscreen.target.read_pixels()) | |
| 120 | pub fn read( | |
| 121 | &self, | |
| 122 | renderer: &Renderer, | |
| 123 | offscreen: Offscreen, | |
| 124 | ) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 125 | Ok(renderer.read_pixels(&offscreen.target)?) | |
| 126 | 126 | } |
| 127 | 127 | } |
crates/snowbound/src/surface_windows.rs+50-40| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and | |
| 2 | //! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB | |
| 3 | //! target, copied to the window's back buffer and swapped. | |
| 1 | //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise | |
| 2 | //! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL | |
| 3 | //! context. The last two draw into an sRGB target, copied to the window's back buffer. | |
| 4 | //! `SNOWBOUND_RENDERER` set to `d3d11` or `gl` picks one of those, and to anything else | |
| 5 | //! insists on Direct3D 12. | |
| 4 | 6 | |
| 5 | 7 | #[path = "surface.rs"] |
| 6 | 8 | mod webgpu; |
| 7 | 9 | |
| 8 | use draw::{GlTarget, Renderer, Target}; | |
| 10 | use draw::{Renderer, Target}; | |
| 9 | 11 | use std::{error::Error, sync::Arc}; |
| 10 | 12 | use windows_sys::Win32::{ |
| 11 | 13 | Foundation::HWND, |
| ... | ... | @@ -27,13 +29,14 @@ pub struct Surface { |
| 27 | 29 | |
| 28 | 30 | enum Backend { |
| 29 | 31 | Wgpu(Box<webgpu::Surface>), |
| 30 | Gl { | |
| 31 | /// The window's device context, which the context draws to. | |
| 32 | device: HDC, | |
| 32 | /// Direct3D 11 or OpenGL, which present the renderer's own targets. | |
| 33 | Native { | |
| 34 | /// For OpenGL, the window's device context, which the context draws to. | |
| 35 | opengl: Option<HDC>, | |
| 33 | 36 | /// Frames leave the desktop's glass showing through their transparent pixels. |
| 34 | 37 | translucent: bool, |
| 35 | 38 | /// The frame target, kept between frames. |
| 36 | target: Option<GlTarget>, | |
| 39 | target: Option<Target>, | |
| 37 | 40 | }, |
| 38 | 41 | } |
| 39 | 42 | |
| ... | ... | @@ -57,22 +60,42 @@ impl Surface { |
| 57 | 60 | backdrop: bool, |
| 58 | 61 | ) -> Result<(Self, Renderer), Box<dyn Error>> { |
| 59 | 62 | let forced = std::env::var("SNOWBOUND_RENDERER").ok(); |
| 60 | if forced.as_deref() != Some("gl") { | |
| 63 | let forced = forced.as_deref(); | |
| 64 | let size = window.inner_size(); | |
| 65 | let size = [size.width, size.height]; | |
| 66 | let native = |opengl, translucent, renderer| { | |
| 67 | let backend = Backend::Native { | |
| 68 | opengl, | |
| 69 | translucent, | |
| 70 | target: None, | |
| 71 | }; | |
| 72 | Ok((Self { size, backend }, renderer)) | |
| 73 | }; | |
| 74 | if !matches!(forced, Some("d3d11" | "gl")) { | |
| 61 | 75 | match webgpu::Surface::new(window.clone(), backdrop).await { |
| 62 | 76 | Ok((surface, renderer)) => { |
| 63 | 77 | let backend = Backend::Wgpu(Box::new(surface)); |
| 64 | let size = window.inner_size(); | |
| 65 | let size = [size.width, size.height]; | |
| 66 | 78 | return Ok((Self { size, backend }, renderer)); |
| 67 | 79 | } |
| 68 | 80 | Err(error) if forced.is_some() => return Err(error), |
| 69 | Err(error) => eprintln!("No Direct3D 12 ({error}); drawing with OpenGL"), | |
| 81 | Err(error) => eprintln!("No Direct3D 12 ({error})"), | |
| 70 | 82 | } |
| 71 | 83 | } |
| 72 | 84 | let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { |
| 73 | 85 | unreachable!() |
| 74 | 86 | }; |
| 75 | let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; | |
| 87 | let hwnd = handle.hwnd.get() as HWND; | |
| 88 | if forced != Some("gl") { | |
| 89 | match Renderer::direct3d11(hwnd) { | |
| 90 | Ok((renderer, adapter)) => { | |
| 91 | eprintln!("Canvas GPU: Direct3D 11 ({adapter})"); | |
| 92 | return native(None, backdrop, renderer); | |
| 93 | } | |
| 94 | Err(error) if forced.is_some() => return Err(error.into()), | |
| 95 | Err(error) => eprintln!("No Direct3D 11 ({error}); drawing with OpenGL"), | |
| 96 | } | |
| 97 | } | |
| 98 | let device = unsafe { GetDC(hwnd) }; | |
| 76 | 99 | let request = |alpha| gl::PIXELFORMATDESCRIPTOR { |
| 77 | 100 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, |
| 78 | 101 | nVersion: 1, |
| ... | ... | @@ -147,25 +170,14 @@ impl Surface { |
| 147 | 170 | the driver from your graphics card's maker. ({error})" |
| 148 | 171 | ) |
| 149 | 172 | })?; |
| 150 | let size = window.inner_size(); | |
| 151 | Ok(( | |
| 152 | Self { | |
| 153 | size: [size.width, size.height], | |
| 154 | backend: Backend::Gl { | |
| 155 | device, | |
| 156 | translucent, | |
| 157 | target: None, | |
| 158 | }, | |
| 159 | }, | |
| 160 | renderer, | |
| 161 | )) | |
| 173 | native(Some(device), translucent, renderer) | |
| 162 | 174 | } |
| 163 | 175 | |
| 164 | 176 | /// Whether frames leave the system's backdrop showing. |
| 165 | 177 | pub fn translucent(&self) -> bool { |
| 166 | 178 | match &self.backend { |
| 167 | 179 | Backend::Wgpu(surface) => surface.translucent(), |
| 168 | Backend::Gl { translucent, .. } => *translucent, | |
| 180 | Backend::Native { translucent, .. } => *translucent, | |
| 169 | 181 | } |
| 170 | 182 | } |
| 171 | 183 | |
| ... | ... | @@ -189,13 +201,13 @@ impl Surface { |
| 189 | 201 | texture: Some((frame.texture, frame.reconfigure)), |
| 190 | 202 | })) |
| 191 | 203 | } |
| 192 | Backend::Gl { target, .. } => { | |
| 204 | Backend::Native { target, .. } => { | |
| 193 | 205 | let target = match target.take() { |
| 194 | 206 | Some(target) if target.size() == self.size => target, |
| 195 | _ => GlTarget::new(self.size)?, | |
| 207 | _ => renderer.target(self.size)?, | |
| 196 | 208 | }; |
| 197 | 209 | Ok(Some(Frame { |
| 198 | target: target.into(), | |
| 210 | target, | |
| 199 | 211 | texture: None, |
| 200 | 212 | })) |
| 201 | 213 | } |
| ... | ... | @@ -213,20 +225,18 @@ impl Surface { |
| 213 | 225 | surface.present(renderer, frame); |
| 214 | 226 | } |
| 215 | 227 | ( |
| 216 | Backend::Gl { | |
| 217 | device, | |
| 228 | Backend::Native { | |
| 229 | opengl, | |
| 218 | 230 | translucent, |
| 219 | 231 | target: kept, |
| 220 | 232 | }, |
| 221 | Target::Gl(target), | |
| 233 | target, | |
| 222 | 234 | None, |
| 223 | 235 | ) => { |
| 224 | if *translucent { | |
| 225 | renderer.present_translucent(&target); | |
| 226 | } else { | |
| 227 | target.present(); | |
| 236 | renderer.present(&target, *translucent); | |
| 237 | if let Some(device) = opengl { | |
| 238 | unsafe { gl::SwapBuffers(*device) }; | |
| 228 | 239 | } |
| 229 | unsafe { gl::SwapBuffers(*device) }; | |
| 230 | 240 | *kept = Some(target); |
| 231 | 241 | } |
| 232 | 242 | _ => unreachable!("Frames come from the surface's own backend"), |
| ... | ... | @@ -244,8 +254,8 @@ impl Surface { |
| 244 | 254 | texture: Some(offscreen.texture), |
| 245 | 255 | }) |
| 246 | 256 | } |
| 247 | Backend::Gl { .. } => Ok(Offscreen { | |
| 248 | target: GlTarget::new(self.size)?.into(), | |
| 257 | Backend::Native { .. } => Ok(Offscreen { | |
| 258 | target: renderer.target(self.size)?, | |
| 249 | 259 | texture: None, |
| 250 | 260 | }), |
| 251 | 261 | } |
| ... | ... | @@ -261,7 +271,7 @@ impl Surface { |
| 261 | 271 | (Backend::Wgpu(surface), target, Some(texture)) => { |
| 262 | 272 | surface.read(renderer, webgpu::Offscreen { target, texture }) |
| 263 | 273 | } |
| 264 | (Backend::Gl { .. }, Target::Gl(target), None) => Ok(target.read_pixels()), | |
| 274 | (Backend::Native { .. }, target, None) => Ok(renderer.read_pixels(&target)?), | |
| 265 | 275 | _ => unreachable!("Snapshots come from the surface's own backend"), |
| 266 | 276 | } |
| 267 | 277 | } |
crates/snowbound/src/sync.rs+417-108| ... | ... | @@ -39,7 +39,7 @@ fn describe( |
| 39 | 39 | quiet: bool, |
| 40 | 40 | ) -> (&'static str, &'static [&'static str]) { |
| 41 | 41 | if offline { |
| 42 | return ("Working offline", art::SYNC_OFFLINE); | |
| 42 | return ("Offline", art::SYNC_OFFLINE); | |
| 43 | 43 | } |
| 44 | 44 | match sync.state() { |
| 45 | 45 | SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), |
| ... | ... | @@ -78,14 +78,6 @@ fn brief(sync: &SyncStatus, offline: bool) -> String { |
| 78 | 78 | /// out the recycle bin, unless something is wrong there. |
| 79 | 79 | fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { |
| 80 | 80 | let open = &session.tabs[session.tab].path; |
| 81 | let copy = |sync: &SyncStatus| SyncStatus { | |
| 82 | synced: sync.synced, | |
| 83 | error: sync | |
| 84 | .error | |
| 85 | .as_ref() | |
| 86 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | |
| 87 | queued: sync.queued, | |
| 88 | }; | |
| 89 | 81 | let mut sections = library |
| 90 | 82 | .background |
| 91 | 83 | .as_ref() |
| ... | ... | @@ -120,13 +112,21 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { |
| 120 | 112 | .map(|(_, sync)| sync.synced) |
| 121 | 113 | .collect::<Option<Vec<_>>>() |
| 122 | 114 | .and_then(|times| times.into_iter().min()), |
| 123 | error: worst | |
| 124 | .and_then(|sync| sync.error.as_ref()) | |
| 125 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | |
| 115 | error: worst.and_then(|sync| copy(sync).error), | |
| 126 | 116 | queued: sections.iter().map(|(_, sync)| sync.queued).sum(), |
| 127 | 117 | } |
| 128 | 118 | } |
| 129 | 119 | |
| 120 | /// `sync` again; its error, which can't be cloned, as one of the same kind and message. | |
| 121 | fn copy(sync: &SyncStatus) -> SyncStatus { | |
| 122 | SyncStatus { | |
| 123 | synced: sync.synced, | |
| 124 | error: (sync.error.as_ref()) | |
| 125 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | |
| 126 | queued: sync.queued, | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | 130 | fn changes(count: u64) -> String { |
| 131 | 131 | match count { |
| 132 | 132 | 1 => "1 change".to_owned(), |
| ... | ... | @@ -175,8 +175,8 @@ const TITLE: &str = "Notebook Sync Status"; |
| 175 | 175 | fn update_note(update: &update::Status) -> Option<String> { |
| 176 | 176 | match update { |
| 177 | 177 | update::Status::Downloading(version) => Some(format!("Downloading {version}…")), |
| 178 | update::Status::Ready(version, _) => Some(format!("{version} is ready")), | |
| 179 | update::Status::Available(version) => Some(format!("{version} is available")), | |
| 178 | update::Status::Ready(version, ..) => Some(format!("{version} is ready")), | |
| 179 | update::Status::Available(version, ..) => Some(format!("{version} is available")), | |
| 180 | 180 | _ => None, |
| 181 | 181 | } |
| 182 | 182 | } |
| ... | ... | @@ -209,7 +209,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t |
| 209 | 209 | ); |
| 210 | 210 | let waiting = matches!( |
| 211 | 211 | update, |
| 212 | update::Status::Ready(..) | update::Status::Available(_) | |
| 212 | update::Status::Ready(..) | update::Status::Available(..) | |
| 213 | 213 | ); |
| 214 | 214 | if waiting { |
| 215 | 215 | ui.leaf( |
| ... | ... | @@ -259,6 +259,8 @@ struct Facts<'a> { |
| 259 | 259 | conflicts: Vec<(ExGuid, &'a str)>, |
| 260 | 260 | offline: bool, |
| 261 | 261 | update: &'a update::Status, |
| 262 | /// Whether the update's changes are listed. | |
| 263 | changes_listed: bool, | |
| 262 | 264 | /// Whether the open section's file can be shown in the file manager. |
| 263 | 265 | local: bool, |
| 264 | 266 | } |
| ... | ... | @@ -273,6 +275,126 @@ struct Picked { |
| 273 | 275 | conflict: Option<ExGuid>, |
| 274 | 276 | build_folder: bool, |
| 275 | 277 | restart: bool, |
| 278 | list_changes: bool, | |
| 279 | } | |
| 280 | ||
| 281 | /// What a newer build changes: `summary`, which unfolds the titles under their kinds when | |
| 282 | /// `listed`. Returns whether the summary was clicked. | |
| 283 | fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: bool) -> bool { | |
| 284 | let theme = ui.theme.clone(); | |
| 285 | let line = theme.font_size * 1.6; | |
| 286 | let toggle = ui.open( | |
| 287 | "summary", | |
| 288 | Spec { | |
| 289 | flags: Flags::CLICKABLE, | |
| 290 | size: [fill(), px(line)], | |
| 291 | gap: 4.0, | |
| 292 | role: Some(accesskit::Role::Button), | |
| 293 | ..Spec::default() | |
| 294 | }, | |
| 295 | ); | |
| 296 | if let Some(node) = ui.access(toggle) { | |
| 297 | node.set_label(summary); | |
| 298 | node.set_expanded(listed); | |
| 299 | } | |
| 300 | ui.leaf( | |
| 301 | "label", | |
| 302 | Spec { | |
| 303 | size: [fit(), px(line)], | |
| 304 | text: Some(summary), | |
| 305 | overflow: Overflow::Ellipsis, | |
| 306 | ..Spec::default() | |
| 307 | }, | |
| 308 | ); | |
| 309 | ui.leaf( | |
| 310 | "chevron", | |
| 311 | Spec { | |
| 312 | size: [fit(), px(line)], | |
| 313 | icon: Some(if listed { | |
| 314 | art::CHEVRON_UP | |
| 315 | } else { | |
| 316 | ui::shell::CHEVRON | |
| 317 | }), | |
| 318 | color: Some(theme.text_dim), | |
| 319 | ..Spec::default() | |
| 320 | }, | |
| 321 | ); | |
| 322 | ui.close(); | |
| 323 | // Unfolds to the titles' height, scrolling past a few dozen lines. | |
| 324 | let list = ui.id("changes"); | |
| 325 | let rows = ui | |
| 326 | .rect(list.child("rows")) | |
| 327 | .map_or(0.0, |rect| rect[3] - rect[1]); | |
| 328 | let height = ui.animate(list, if listed { rows.min(line * 9.0) } else { 0.0 }); | |
| 329 | if listed || height > 0.0 { | |
| 330 | ui.open_as( | |
| 331 | list, | |
| 332 | Spec { | |
| 333 | flags: Flags::SCROLL | Flags::CLIP, | |
| 334 | axis: Axis::Y, | |
| 335 | size: [fill(), px(height)], | |
| 336 | role: Some(accesskit::Role::List), | |
| 337 | ..Spec::default() | |
| 338 | }, | |
| 339 | ); | |
| 340 | ui.open( | |
| 341 | "rows", | |
| 342 | Spec { | |
| 343 | axis: Axis::Y, | |
| 344 | size: [fill(), children()], | |
| 345 | pad: [0.0, 2.0], | |
| 346 | gap: 2.0, | |
| 347 | ..Spec::default() | |
| 348 | }, | |
| 349 | ); | |
| 350 | let mut kind = None; | |
| 351 | for (index, change) in changes.iter().enumerate() { | |
| 352 | if kind != Some(change.kind) { | |
| 353 | kind = Some(change.kind); | |
| 354 | ui.leaf( | |
| 355 | ("heading", index), | |
| 356 | Spec { | |
| 357 | size: [fill(), px(line)], | |
| 358 | text: Some(change.kind.heading()), | |
| 359 | color: Some(theme.text_dim), | |
| 360 | bold: true, | |
| 361 | ..Spec::default() | |
| 362 | }, | |
| 363 | ); | |
| 364 | } | |
| 365 | ui.open( | |
| 366 | ("change", index), | |
| 367 | Spec { | |
| 368 | size: [fill(), children()], | |
| 369 | gap: 6.0, | |
| 370 | role: Some(accesskit::Role::ListItem), | |
| 371 | ..Spec::default() | |
| 372 | }, | |
| 373 | ); | |
| 374 | ui.leaf( | |
| 375 | "bullet", | |
| 376 | Spec { | |
| 377 | size: [fit(), fit()], | |
| 378 | text: Some("•"), | |
| 379 | color: Some(theme.text_dim), | |
| 380 | ..Spec::default() | |
| 381 | }, | |
| 382 | ); | |
| 383 | ui.leaf( | |
| 384 | "title", | |
| 385 | Spec { | |
| 386 | size: [fill(), fit()], | |
| 387 | text: Some(&change.title), | |
| 388 | overflow: Overflow::Wrap, | |
| 389 | ..Spec::default() | |
| 390 | }, | |
| 391 | ); | |
| 392 | ui.close(); | |
| 393 | } | |
| 394 | ui.close(); | |
| 395 | ui.close(); | |
| 396 | } | |
| 397 | ui.signal(toggle).clicked | |
| 276 | 398 | } |
| 277 | 399 | |
| 278 | 400 | /// Lays out the open popup's contents. |
| ... | ... | @@ -320,10 +442,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 320 | 442 | let waiting = changes(sync.queued); |
| 321 | 443 | let conflict; |
| 322 | 444 | let advice = if facts.offline { |
| 323 | Some(match sync.queued { | |
| 324 | 0 => "Turn off Work offline to sync.".to_owned(), | |
| 325 | _ => format!("{waiting} will sync when you go back online."), | |
| 326 | }) | |
| 445 | None | |
| 327 | 446 | } else { |
| 328 | 447 | match state { |
| 329 | 448 | SyncState::NotConnected => Some(match sync.queued { |
| ... | ... | @@ -354,7 +473,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 354 | 473 | icon = art::SYNC_WARNING; |
| 355 | 474 | } |
| 356 | 475 | |
| 357 | // The status: headline, when it last synced or what to do, and a running sync's bar. | |
| 476 | // The status: headline and Work offline's switch, what to do, then one line of when it | |
| 477 | // last synced or how a sync runs, over a running sync's bar. Its height holds as Work | |
| 478 | // offline turns on and off. | |
| 358 | 479 | ui.open( |
| 359 | 480 | "status", |
| 360 | 481 | Spec { |
| ... | ... | @@ -367,10 +488,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 367 | 488 | if let Some(node) = ui.access(ui.current()) { |
| 368 | 489 | node.set_live(accesskit::Live::Polite); |
| 369 | 490 | } |
| 491 | let height = theme.font_size * 2.0; | |
| 492 | ui.open( | |
| 493 | "header", | |
| 494 | Spec { | |
| 495 | size: [fill(), px(height)], | |
| 496 | gap: 8.0, | |
| 497 | ..Spec::default() | |
| 498 | }, | |
| 499 | ); | |
| 370 | 500 | ui.leaf( |
| 371 | 501 | "headline", |
| 372 | 502 | Spec { |
| 373 | size: [fill(), px(theme.font_size * 2.0)], | |
| 503 | size: [fill(), px(height)], | |
| 374 | 504 | text: Some(headline), |
| 375 | 505 | icon: Some(icon), |
| 376 | 506 | font_size: Some(theme.font_size * 1.2), |
| ... | ... | @@ -379,12 +509,118 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 379 | 509 | ..Spec::default() |
| 380 | 510 | }, |
| 381 | 511 | ); |
| 382 | if showing { | |
| 383 | let fraction = match total { | |
| 384 | 0 => 1.0, | |
| 385 | _ if state == SyncState::UpToDate => 1.0, | |
| 386 | _ => done as f32 / total as f32, | |
| 512 | let switch = ui.open( | |
| 513 | "offline", | |
| 514 | Spec { | |
| 515 | flags: Flags::CLICKABLE, | |
| 516 | size: [children(), px(height)], | |
| 517 | gap: 8.0, | |
| 518 | role: Some(accesskit::Role::Switch), | |
| 519 | ..Spec::default() | |
| 520 | }, | |
| 521 | ); | |
| 522 | if let Some(node) = ui.access(switch) { | |
| 523 | node.set_label("Work offline"); | |
| 524 | node.set_toggled(facts.offline.into()); | |
| 525 | } | |
| 526 | let signal = ui.signal(switch); | |
| 527 | picked.offline = signal.clicked; | |
| 528 | ui.leaf( | |
| 529 | "label", | |
| 530 | Spec { | |
| 531 | size: [fit(), px(height)], | |
| 532 | text: Some("Work offline"), | |
| 533 | color: Some(theme.text_dim), | |
| 534 | ..Spec::default() | |
| 535 | }, | |
| 536 | ); | |
| 537 | let [width, side] = [30.0, 18.0]; | |
| 538 | let on = ui.animate(switch.child("on"), f32::from(u8::from(facts.offline))); | |
| 539 | let mix = |from: [f32; 4], to: [f32; 4]| { | |
| 540 | std::array::from_fn(|at| from[at] + (to[at] - from[at]) * on) | |
| 541 | }; | |
| 542 | ui.open( | |
| 543 | "track", | |
| 544 | Spec { | |
| 545 | size: [px(width), px(height)], | |
| 546 | inset: [0.0, (height - side) / 2.0, 0.0, (height - side) / 2.0], | |
| 547 | fill: Some(mix(theme.chip, theme.accent)), | |
| 548 | border: signal.hovered.then_some(theme.accent), | |
| 549 | radius: side / 2.0, | |
| 550 | ..Spec::default() | |
| 551 | }, | |
| 552 | ); | |
| 553 | let knob = side - 4.0; | |
| 554 | let left = 2.0 + (width - side) * on; | |
| 555 | let top = (height - side) / 2.0 + 2.0; | |
| 556 | ui.mark([left, top, left + knob, top + knob], [1.0; 4], knob / 2.0); | |
| 557 | ui.close(); | |
| 558 | ui.close(); | |
| 559 | ui.close(); | |
| 560 | if let Some(advice) = &advice { | |
| 561 | text(ui, "advice", advice, theme.text); | |
| 562 | } else if !facts.conflicts.is_empty() && !showing { | |
| 563 | text( | |
| 564 | ui, | |
| 565 | "advice", | |
| 566 | "Both versions are kept. Open the page to compare them.", | |
| 567 | theme.text, | |
| 568 | ); | |
| 569 | } | |
| 570 | let fraction = match total { | |
| 571 | 0 => 1.0, | |
| 572 | _ if state == SyncState::UpToDate => 1.0, | |
| 573 | _ => done as f32 / total as f32, | |
| 574 | }; | |
| 575 | let subtitle = if showing { | |
| 576 | let mut caption = match total { | |
| 577 | 0 | 1 => String::new(), | |
| 578 | _ => format!("{done} of {total} sections synced"), | |
| 579 | }; | |
| 580 | if sync.queued > 0 { | |
| 581 | if !caption.is_empty() { | |
| 582 | caption += " · "; | |
| 583 | } | |
| 584 | caption += &format!("{waiting} to send"); | |
| 585 | } | |
| 586 | caption | |
| 587 | } else if facts.offline && sync.queued > 0 { | |
| 588 | format!("{waiting} waiting") | |
| 589 | } else if let Some(synced) = sync.synced { | |
| 590 | ui.wake_after(Duration::from_secs(30)); | |
| 591 | let prefix = match advice.is_some() || facts.offline { | |
| 592 | true => "Last synced", | |
| 593 | false => "Synced", | |
| 387 | 594 | }; |
| 595 | format!("{prefix} {}", ago(synced)) | |
| 596 | } else { | |
| 597 | String::new() | |
| 598 | }; | |
| 599 | let subtitle = match subtitle.is_empty() { | |
| 600 | true => "Checking for changes…", | |
| 601 | false => &subtitle, | |
| 602 | }; | |
| 603 | ui.leaf( | |
| 604 | "subtitle", | |
| 605 | Spec { | |
| 606 | size: [fill(), px(theme.font_size * 1.5)], | |
| 607 | text: Some(subtitle), | |
| 608 | color: Some(theme.text_dim), | |
| 609 | overflow: Overflow::Ellipsis, | |
| 610 | ..Spec::default() | |
| 611 | }, | |
| 612 | ); | |
| 613 | ui.close(); | |
| 614 | ||
| 615 | // A running sync's bar takes the rule's place, which keeps the bar's height. | |
| 616 | ui.open( | |
| 617 | "progress", | |
| 618 | Spec { | |
| 619 | size: [fill(), px(6.0)], | |
| 620 | ..Spec::default() | |
| 621 | }, | |
| 622 | ); | |
| 623 | if showing { | |
| 388 | 624 | let fraction = ui.animate(bar.child("fraction"), fraction); |
| 389 | 625 | let running = ui.lasted(bar.child("running"), true).as_secs_f32(); |
| 390 | 626 | ui.open_as( |
| ... | ... | @@ -419,48 +655,18 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 419 | 655 | node.set_numeric_value(f64::from((fraction * 100.0).round())); |
| 420 | 656 | } |
| 421 | 657 | ui.close(); |
| 422 | let mut caption = match total { | |
| 423 | 0 | 1 => String::new(), | |
| 424 | _ => format!("{done} of {total} sections synced"), | |
| 425 | }; | |
| 426 | if sync.queued > 0 { | |
| 427 | if !caption.is_empty() { | |
| 428 | caption += " · "; | |
| 429 | } | |
| 430 | caption += &format!("{waiting} to send"); | |
| 431 | } | |
| 432 | if caption.is_empty() { | |
| 433 | caption = "Checking for changes…".to_owned(); | |
| 434 | } | |
| 435 | text(ui, "caption", &caption, theme.text_dim); | |
| 436 | 658 | } else { |
| 437 | if let Some(advice) = &advice { | |
| 438 | text(ui, "advice", advice, theme.text); | |
| 439 | } else if !facts.conflicts.is_empty() && headline != "Syncing…" { | |
| 440 | text( | |
| 441 | ui, | |
| 442 | "advice", | |
| 443 | "Both versions are kept. Open the page to compare them.", | |
| 444 | theme.text, | |
| 445 | ); | |
| 446 | } | |
| 447 | if let Some(synced) = sync.synced { | |
| 448 | let prefix = match advice { | |
| 449 | Some(_) => "Last synced", | |
| 450 | None => "Synced", | |
| 451 | }; | |
| 452 | text( | |
| 453 | ui, | |
| 454 | "synced", | |
| 455 | &format!("{prefix} {}", ago(synced)), | |
| 456 | theme.text_dim, | |
| 457 | ); | |
| 458 | ui.wake_after(Duration::from_secs(30)); | |
| 459 | } | |
| 659 | ui.leaf( | |
| 660 | "rule", | |
| 661 | Spec { | |
| 662 | size: [fill(), px(6.0)], | |
| 663 | inset: [0.0, 2.5, 0.0, 2.5], | |
| 664 | fill: Some(theme.chip), | |
| 665 | ..Spec::default() | |
| 666 | }, | |
| 667 | ); | |
| 460 | 668 | } |
| 461 | 669 | ui.close(); |
| 462 | ||
| 463 | rule(ui, "identity-rule"); | |
| 464 | 670 | // Which notebook, and where it lives; the full location in the tooltip. |
| 465 | 671 | ui.open( |
| 466 | 672 | "identity", |
| ... | ... | @@ -528,9 +734,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 528 | 734 | ); |
| 529 | 735 | // Where the whole notebook can't be reached, the headline says so for each section |
| 530 | 736 | // without changes waiting. |
| 531 | let unreached = |sync: &SyncStatus| { | |
| 532 | !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0 | |
| 533 | }; | |
| 737 | let unreached = |sync: &SyncStatus| sync.state() == SyncState::NotConnected && sync.queued == 0; | |
| 534 | 738 | let behind: Vec<_> = facts |
| 535 | 739 | .sections |
| 536 | 740 | .iter() |
| ... | ... | @@ -650,17 +854,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 650 | 854 | ui.open( |
| 651 | 855 | "update", |
| 652 | 856 | Spec { |
| 857 | axis: Axis::Y, | |
| 653 | 858 | size: [fill(), children()], |
| 859 | gap: 4.0, | |
| 860 | ..Spec::default() | |
| 861 | }, | |
| 862 | ); | |
| 863 | text(ui, "note", &format!("Snowbound {note}"), theme.text); | |
| 864 | if let update::Status::Ready(_, _, changes) | update::Status::Available(_, changes) = | |
| 865 | facts.update | |
| 866 | && let Some(summary) = update::summary(changes) | |
| 867 | { | |
| 868 | picked.list_changes = changes_list(ui, &summary, changes, facts.changes_listed); | |
| 869 | } | |
| 870 | ui.open( | |
| 871 | "actions", | |
| 872 | Spec { | |
| 873 | size: [fill(), children()], | |
| 874 | pad: [0.0, 4.0], | |
| 654 | 875 | gap: 8.0, |
| 655 | 876 | ..Spec::default() |
| 656 | 877 | }, |
| 657 | 878 | ); |
| 658 | 879 | ui.leaf( |
| 659 | "note", | |
| 880 | "space", | |
| 660 | 881 | Spec { |
| 661 | size: [fill(), px(theme.font_size * 2.0)], | |
| 662 | text: Some(&format!("Snowbound {note}")), | |
| 663 | overflow: Overflow::Ellipsis, | |
| 882 | size: [fill(), px(1.0)], | |
| 664 | 883 | ..Spec::default() |
| 665 | 884 | }, |
| 666 | 885 | ); |
| ... | ... | @@ -669,6 +888,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 669 | 888 | picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; |
| 670 | 889 | } |
| 671 | 890 | ui.close(); |
| 891 | ui.close(); | |
| 672 | 892 | } |
| 673 | 893 | |
| 674 | 894 | ui.open( |
| ... | ... | @@ -680,7 +900,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 680 | 900 | ..Spec::default() |
| 681 | 901 | }, |
| 682 | 902 | ); |
| 683 | picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked; | |
| 684 | 903 | ui.leaf( |
| 685 | 904 | "space", |
| 686 | 905 | Spec { |
| ... | ... | @@ -688,30 +907,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 688 | 907 | ..Spec::default() |
| 689 | 908 | }, |
| 690 | 909 | ); |
| 691 | picked.show_file = match facts.local { | |
| 692 | true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, | |
| 693 | false => { | |
| 694 | ui.leaf( | |
| 695 | "show-file", | |
| 696 | Spec { | |
| 697 | size: [fit(), px(theme.font_size * 2.0)], | |
| 698 | text: Some(platform::SHOW_FILE), | |
| 699 | color: Some(theme.text_dim), | |
| 700 | fill: Some(theme.chip), | |
| 701 | radius: 4.0, | |
| 702 | pad: [theme.font_size * 0.75, 0.0], | |
| 703 | center: true, | |
| 704 | role: Some(accesskit::Role::Button), | |
| 705 | ..Spec::default() | |
| 706 | }, | |
| 707 | ); | |
| 708 | if let Some(node) = ui.access(ui.id("show-file")) { | |
| 709 | node.set_disabled(); | |
| 710 | } | |
| 711 | false | |
| 910 | // A button that can't act now, dimmed. | |
| 911 | let button = |ui: &mut Ui, part: &str, label: &str, enabled: bool| { | |
| 912 | if enabled { | |
| 913 | return ui::button(ui, part, label).clicked; | |
| 712 | 914 | } |
| 915 | ui.leaf( | |
| 916 | part, | |
| 917 | Spec { | |
| 918 | size: [fit(), px(theme.font_size * 2.0)], | |
| 919 | text: Some(label), | |
| 920 | color: Some(theme.text_dim), | |
| 921 | fill: Some(theme.chip), | |
| 922 | radius: 4.0, | |
| 923 | pad: [theme.font_size * 0.75, 0.0], | |
| 924 | center: true, | |
| 925 | role: Some(accesskit::Role::Button), | |
| 926 | ..Spec::default() | |
| 927 | }, | |
| 928 | ); | |
| 929 | if let Some(node) = ui.access(ui.id(part)) { | |
| 930 | node.set_disabled(); | |
| 931 | } | |
| 932 | false | |
| 713 | 933 | }; |
| 714 | picked.sync_now = ui::button(ui, "sync-now", "Sync Now").clicked; | |
| 934 | picked.show_file = button(ui, "show-file", platform::SHOW_FILE, facts.local); | |
| 935 | picked.sync_now = button(ui, "sync-now", "Sync Now", !facts.offline); | |
| 715 | 936 | ui.close(); |
| 716 | 937 | picked |
| 717 | 938 | } |
| ... | ... | @@ -817,6 +1038,7 @@ impl State { |
| 817 | 1038 | .collect(), |
| 818 | 1039 | offline, |
| 819 | 1040 | update: &update, |
| 1041 | changes_listed: self.updates.changes_listed, | |
| 820 | 1042 | local: file.is_some(), |
| 821 | 1043 | }; |
| 822 | 1044 | open(ui, anchor); |
| ... | ... | @@ -852,13 +1074,14 @@ impl State { |
| 852 | 1074 | } |
| 853 | 1075 | match update { |
| 854 | 1076 | update::Status::Downloading(version) |
| 855 | | update::Status::Ready(version, _) | |
| 856 | | update::Status::Available(version) | |
| 1077 | | update::Status::Ready(version, ..) | |
| 1078 | | update::Status::Available(version, ..) | |
| 857 | 1079 | if picked.build_folder => |
| 858 | 1080 | { |
| 859 | 1081 | update::show_build(&version) |
| 860 | 1082 | } |
| 861 | 1083 | _ if picked.restart => self.restart_to_update(), |
| 1084 | _ if picked.list_changes => self.updates.changes_listed ^= true, | |
| 862 | 1085 | _ => {} |
| 863 | 1086 | } |
| 864 | 1087 | Ok(()) |
| ... | ... | @@ -915,6 +1138,7 @@ mod tests { |
| 915 | 1138 | conflicts: Vec::new(), |
| 916 | 1139 | offline: false, |
| 917 | 1140 | update: &IDLE, |
| 1141 | changes_listed: false, | |
| 918 | 1142 | local: true, |
| 919 | 1143 | } |
| 920 | 1144 | } |
| ... | ... | @@ -1110,16 +1334,11 @@ mod tests { |
| 1110 | 1334 | assert!(!bar(&ui)); |
| 1111 | 1335 | } |
| 1112 | 1336 | |
| 1113 | #[cfg(all(feature = "wgpu", not(windows)))] | |
| 1114 | #[test] | |
| 1115 | fn popup_renders_each_state() { | |
| 1116 | let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); | |
| 1117 | let mut gpu = output.as_ref().map(|output| { | |
| 1118 | std::fs::create_dir_all(output).unwrap(); | |
| 1119 | Gpu::new() | |
| 1120 | }); | |
| 1121 | type Made = fn() -> Facts<'static>; | |
| 1122 | let states: Vec<(&str, Made)> = vec![ | |
| 1337 | type Made = fn() -> Facts<'static>; | |
| 1338 | ||
| 1339 | /// Each state the popup shows, by name. | |
| 1340 | fn states() -> Vec<(&'static str, Made)> { | |
| 1341 | vec![ | |
| 1123 | 1342 | ("up-to-date", || facts(sections(|_| status(true, 0, None)))), |
| 1124 | 1343 | ("syncing", || { |
| 1125 | 1344 | facts(sections(|index| match index { |
| ... | ... | @@ -1178,7 +1397,71 @@ mod tests { |
| 1178 | 1397 | place: vec!["iCloud Drive".into(), "Notes".into()], |
| 1179 | 1398 | ..facts(sections(|_| status(true, 0, None))) |
| 1180 | 1399 | }), |
| 1181 | ]; | |
| 1400 | ("update", || Facts { | |
| 1401 | update: ready(), | |
| 1402 | ..facts(sections(|_| status(true, 0, None))) | |
| 1403 | }), | |
| 1404 | ("update-listed", || Facts { | |
| 1405 | update: ready(), | |
| 1406 | changes_listed: true, | |
| 1407 | ..facts(sections(|_| status(true, 0, None))) | |
| 1408 | }), | |
| 1409 | ] | |
| 1410 | } | |
| 1411 | ||
| 1412 | /// A staged build that brings a little of each kind. | |
| 1413 | fn ready() -> &'static update::Status { | |
| 1414 | let changes = serde_json::from_value(serde_json::json!([ | |
| 1415 | {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"}, | |
| 1416 | {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"}, | |
| 1417 | {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"}, | |
| 1418 | {"version": "2026-10-01-r4", "kind": "fix", "title": "Section copies keep their own TOC entries"}, | |
| 1419 | {"version": "2026-10-01-r4", "kind": "fix", "title": "Pasted pictures keep the size OneNote 2010 gives them"}, | |
| 1420 | {"version": "2026-10-01-r5", "kind": "other", "title": "Drop leftover debug output"}, | |
| 1421 | ])) | |
| 1422 | .unwrap(); | |
| 1423 | Box::leak(Box::new(update::Status::Ready( | |
| 1424 | update::Version::parse("2026-10-01-r5").unwrap(), | |
| 1425 | std::path::PathBuf::new(), | |
| 1426 | changes, | |
| 1427 | ))) | |
| 1428 | } | |
| 1429 | ||
| 1430 | /// Work offline's switch stays put as it turns on and off, and so do the buttons below | |
| 1431 | /// but where a problem's advice, which offline leaves out, goes and comes back. | |
| 1432 | #[test] | |
| 1433 | fn toggling_offline_moves_nothing() { | |
| 1434 | let switch = id().child("status").child("header").child("offline"); | |
| 1435 | let controls = id().child("controls"); | |
| 1436 | for (name, made) in states() { | |
| 1437 | let advised = !matches!( | |
| 1438 | overall(&made().sections).state(), | |
| 1439 | SyncState::UpToDate | SyncState::Syncing | |
| 1440 | ); | |
| 1441 | let mut places = Vec::new(); | |
| 1442 | for offline in [false, true, false] { | |
| 1443 | let mut ui = ui(Appearance::Light); | |
| 1444 | let mut now = Instant::now(); | |
| 1445 | let facts = Facts { offline, ..made() }; | |
| 1446 | settle(&mut ui, &mut now, &facts); | |
| 1447 | places.push((ui.rect(switch).unwrap(), ui.rect(controls).unwrap())); | |
| 1448 | } | |
| 1449 | for pair in places.windows(2) { | |
| 1450 | assert_eq!(pair[0].0, pair[1].0, "{name}"); | |
| 1451 | assert!(advised || pair[0].1 == pair[1].1, "{name}: {places:?}"); | |
| 1452 | } | |
| 1453 | } | |
| 1454 | } | |
| 1455 | ||
| 1456 | #[cfg(all(feature = "wgpu", not(windows)))] | |
| 1457 | #[test] | |
| 1458 | fn popup_renders_each_state() { | |
| 1459 | let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); | |
| 1460 | let mut gpu = output.as_ref().map(|output| { | |
| 1461 | std::fs::create_dir_all(output).unwrap(); | |
| 1462 | Gpu::new() | |
| 1463 | }); | |
| 1464 | let states = states(); | |
| 1182 | 1465 | for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { |
| 1183 | 1466 | for (name, facts) in &states { |
| 1184 | 1467 | let mut ui = ui(appearance); |
| ... | ... | @@ -1226,6 +1509,32 @@ mod tests { |
| 1226 | 1509 | shot += 1; |
| 1227 | 1510 | } |
| 1228 | 1511 | } |
| 1512 | // Work offline on, an edit waiting, then off: the edit sends and nothing moves. | |
| 1513 | let frames = output.join(format!("toggle-{theme}")); | |
| 1514 | std::fs::create_dir_all(&frames).unwrap(); | |
| 1515 | let mut ui = self::ui(appearance); | |
| 1516 | let mut now = Instant::now(); | |
| 1517 | settle( | |
| 1518 | &mut ui, | |
| 1519 | &mut now, | |
| 1520 | &facts(sections(|_| status(true, 0, None))), | |
| 1521 | ); | |
| 1522 | for tick in 0..360 { | |
| 1523 | let seconds = tick as f32 / 60.0; | |
| 1524 | let queued = u64::from((2.0..4.6).contains(&seconds)) * 2; | |
| 1525 | let facts = Facts { | |
| 1526 | offline: (1.0..3.2).contains(&seconds), | |
| 1527 | ..facts(sections(|index| { | |
| 1528 | status(true, u64::from(index == 1) * queued, None) | |
| 1529 | })) | |
| 1530 | }; | |
| 1531 | now += Duration::from_micros(16_667); | |
| 1532 | frame(&mut ui, now, &facts); | |
| 1533 | if tick % 2 == 0 { | |
| 1534 | let path = frames.join(format!("{:03}.png", tick / 2)); | |
| 1535 | gpu.save(&ui, &path, Some(360.0)); | |
| 1536 | } | |
| 1537 | } | |
| 1229 | 1538 | } |
| 1230 | 1539 | } |
| 1231 | 1540 | } |
crates/snowbound/src/tags.rs+2-22| ... | ... | @@ -345,26 +345,6 @@ fn picker( |
| 345 | 345 | Anchor::Below(ui.rect(button).unwrap_or_default()) |
| 346 | 346 | } |
| 347 | 347 | |
| 348 | /// The trailing row of a dialog: its buttons at the right. | |
| 349 | fn buttons(ui: &mut Ui) { | |
| 350 | ui.open( | |
| 351 | "buttons", | |
| 352 | Spec { | |
| 353 | size: [fill(), children()], | |
| 354 | pad: [0.0, 8.0], | |
| 355 | gap: 8.0, | |
| 356 | ..Spec::default() | |
| 357 | }, | |
| 358 | ); | |
| 359 | ui.leaf( | |
| 360 | "space", | |
| 361 | Spec { | |
| 362 | size: [fill(), px(1.0)], | |
| 363 | ..Spec::default() | |
| 364 | }, | |
| 365 | ); | |
| 366 | } | |
| 367 | ||
| 368 | 348 | /// The frame of a dialog titled `title`, centred in the window. |
| 369 | 349 | fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { |
| 370 | 350 | let theme = ui.theme.clone(); |
| ... | ... | @@ -545,7 +525,7 @@ impl State { |
| 545 | 525 | .flatten() |
| 546 | 526 | }); |
| 547 | 527 | ui.close(); |
| 548 | buttons(ui); | |
| 528 | crate::buttons(ui); | |
| 549 | 529 | let ok = button(ui, "ok", "OK", list.tags != self.tags); |
| 550 | 530 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 551 | 531 | ui.close(); |
| ... | ... | @@ -880,7 +860,7 @@ fn tag_editor( |
| 880 | 860 | ..Spec::default() |
| 881 | 861 | }, |
| 882 | 862 | ); |
| 883 | buttons(ui); | |
| 863 | crate::buttons(ui); | |
| 884 | 864 | let named = !tag.label.trim().is_empty(); |
| 885 | 865 | let ok = button(ui, "ok", "OK", named) || entered && named; |
| 886 | 866 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
crates/snowbound/src/themes.rs+1-16| ... | ... | @@ -156,22 +156,7 @@ impl State { |
| 156 | 156 | return Ok(()); |
| 157 | 157 | } |
| 158 | 158 | self.persist()?; |
| 159 | let Some(session) = &self.session else { | |
| 160 | return Ok(()); | |
| 161 | }; | |
| 162 | let space = session.space; | |
| 163 | session.section.apply( | |
| 164 | &self.author, | |
| 165 | onestore::op::Edit { | |
| 166 | at: filetime(), | |
| 167 | ops: ops | |
| 168 | .into_iter() | |
| 169 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 170 | .collect(), | |
| 171 | }, | |
| 172 | )?; | |
| 173 | self.edited(vec![space]); | |
| 174 | self.refresh() | |
| 159 | self.edit_page(ops) | |
| 175 | 160 | } |
| 176 | 161 | |
| 177 | 162 | /// Opens the Themes dialog choosing a theme for `scope` of the open page. |
crates/snowbound/src/undo.rs+53-59| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | //! window keeps what was done to pages and sections between those edits, so Undo takes back |
| 4 | 4 | //! whichever came last where the user is. Every step taken back is an edit of its own. |
| 5 | 5 | |
| 6 | use crate::library::{entry_name, folders}; | |
| 6 | 7 | use crate::{Command, Library, Loaded, State, UserEvent}; |
| 7 | 8 | use canvas::editor::CanvasEditor; |
| 8 | 9 | use notebook::{Replica, discover::Folder}; |
| ... | ... | @@ -287,7 +288,7 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { |
| 287 | 288 | } |
| 288 | 289 | |
| 289 | 290 | /// Whether `page` holds nothing but its title, untitled. |
| 290 | fn blank(page: &Page) -> bool { | |
| 291 | pub(crate) fn blank(page: &Page) -> bool { | |
| 291 | 292 | page.title.trim().is_empty() |
| 292 | 293 | && page |
| 293 | 294 | .objects |
| ... | ... | @@ -353,6 +354,46 @@ impl Site { |
| 353 | 354 | Ok(()) |
| 354 | 355 | } |
| 355 | 356 | |
| 357 | /// The page shown once `gone` leave `listed`, preferring `prefer`, and the page `ops` gain | |
| 358 | /// where none stays. | |
| 359 | fn after( | |
| 360 | &self, | |
| 361 | listed: &[(ExGuid, String, u32)], | |
| 362 | gone: &[ExGuid], | |
| 363 | prefer: Option<ExGuid>, | |
| 364 | ops: &mut Vec<Op>, | |
| 365 | ) -> (ExGuid, Option<ExGuid>) { | |
| 366 | match neighbor(listed, gone, prefer) { | |
| 367 | Some(show) => (show, None), | |
| 368 | None => { | |
| 369 | let space = self.fresh.space(); | |
| 370 | ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); | |
| 371 | (space, Some(space)) | |
| 372 | } | |
| 373 | } | |
| 374 | } | |
| 375 | ||
| 376 | /// Takes out `added`, the page a section left without pages gained, in `ops` while | |
| 377 | /// `listed` holds it and it holds nothing; the pages going. | |
| 378 | fn take_added( | |
| 379 | &self, | |
| 380 | added: Option<ExGuid>, | |
| 381 | listed: &[(ExGuid, String, u32)], | |
| 382 | ops: &mut Vec<Op>, | |
| 383 | ) -> Vec<ExGuid> { | |
| 384 | let gone: Vec<ExGuid> = added | |
| 385 | .filter(|added| { | |
| 386 | listed.iter().any(|(space, ..)| space == added) | |
| 387 | && self.replica.page(*added).is_ok_and(|page| blank(&page)) | |
| 388 | }) | |
| 389 | .into_iter() | |
| 390 | .collect(); | |
| 391 | if !gone.is_empty() { | |
| 392 | ops.push(Op::Section(SectionOp::Delete(gone.clone()))); | |
| 393 | } | |
| 394 | gone | |
| 395 | } | |
| 396 | ||
| 356 | 397 | /// The catalog path of the section `identity` names. |
| 357 | 398 | fn section_path(&self, identity: [u8; 16]) -> Option<String> { |
| 358 | 399 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) |
| ... | ... | @@ -397,14 +438,7 @@ impl Site { |
| 397 | 438 | }) |
| 398 | 439 | .collect(); |
| 399 | 440 | let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; |
| 400 | let (show, added) = match neighbor(&listed, &pages, show) { | |
| 401 | Some(show) => (show, None), | |
| 402 | None => { | |
| 403 | let space = self.fresh.space(); | |
| 404 | ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); | |
| 405 | (space, Some(space)) | |
| 406 | } | |
| 407 | }; | |
| 441 | let (show, added) = self.after(&listed, &pages, show, &mut ops); | |
| 408 | 442 | if !created && binned { |
| 409 | 443 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); |
| 410 | 444 | self.library.reopen()?.recycle_pages(&pages, &self.author)?; |
| ... | ... | @@ -452,15 +486,7 @@ impl Site { |
| 452 | 486 | )?]))); |
| 453 | 487 | spaces.push(space); |
| 454 | 488 | } |
| 455 | let gone: Vec<ExGuid> = added | |
| 456 | .filter(|added| { | |
| 457 | at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) | |
| 458 | }) | |
| 459 | .into_iter() | |
| 460 | .collect(); | |
| 461 | if !gone.is_empty() { | |
| 462 | ops.push(Op::Section(SectionOp::Delete(gone.clone()))); | |
| 463 | } | |
| 489 | let gone = self.take_added(added, &listed, &mut ops); | |
| 464 | 490 | self.apply(ops)?; |
| 465 | 491 | if !created && binned { |
| 466 | 492 | let identities: Vec<[u8; 16]> = |
| ... | ... | @@ -531,14 +557,7 @@ impl Site { |
| 531 | 557 | let before = listed[from + 1..].first().map(|(space, ..)| *space); |
| 532 | 558 | let level = listed[from].2; |
| 533 | 559 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; |
| 534 | let (show, added) = match neighbor(&listed, &[page], None) { | |
| 535 | Some(show) => (show, None), | |
| 536 | None => { | |
| 537 | let space = self.fresh.space(); | |
| 538 | ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); | |
| 539 | (space, Some(space)) | |
| 540 | } | |
| 541 | }; | |
| 560 | let (show, added) = self.after(&listed, &[page], None, &mut ops); | |
| 542 | 561 | let section = self.library.open(&path, || {})?; |
| 543 | 562 | section.replica().apply( |
| 544 | 563 | &self.author, |
| ... | ... | @@ -590,15 +609,7 @@ impl Site { |
| 590 | 609 | level, |
| 591 | 610 | )?])), |
| 592 | 611 | ]; |
| 593 | let gone: Vec<ExGuid> = added | |
| 594 | .filter(|added| { | |
| 595 | at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) | |
| 596 | }) | |
| 597 | .into_iter() | |
| 598 | .collect(); | |
| 599 | if !gone.is_empty() { | |
| 600 | ops.push(Op::Section(SectionOp::Delete(gone.clone()))); | |
| 601 | } | |
| 612 | let gone = self.take_added(added, &listed, &mut ops); | |
| 602 | 613 | self.apply(ops)?; |
| 603 | 614 | section.replica().apply( |
| 604 | 615 | &self.author, |
| ... | ... | @@ -641,21 +652,10 @@ fn folder_id(folder: &Folder) -> Option<[u8; 16]> { |
| 641 | 652 | (!folder.path.is_empty()).then_some(toc.file_id) |
| 642 | 653 | } |
| 643 | 654 | |
| 644 | /// Every folder of `catalog`, the notebook's own first. | |
| 645 | fn folders(catalog: &Folder) -> Vec<&Folder> { | |
| 646 | let mut all = vec![catalog]; | |
| 647 | let mut at = 0; | |
| 648 | while let Some(folder) = all.get(at) { | |
| 649 | all.extend(&folder.groups); | |
| 650 | at += 1; | |
| 651 | } | |
| 652 | all | |
| 653 | } | |
| 654 | ||
| 655 | 655 | /// The folder `identity` names, the notebook's own without one. |
| 656 | 656 | fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { |
| 657 | 657 | match identity { |
| 658 | Some(_) => folders(catalog) | |
| 658 | Some(_) => folders(catalog, |_| true) | |
| 659 | 659 | .into_iter() |
| 660 | 660 | .find(|folder| folder_id(folder) == identity), |
| 661 | 661 | None => Some(catalog), |
| ... | ... | @@ -664,7 +664,7 @@ fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { |
| 664 | 664 | |
| 665 | 665 | /// The section or group `identity` names: its folder and catalog path. |
| 666 | 666 | fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { |
| 667 | folders(catalog).into_iter().find_map(|folder| { | |
| 667 | folders(catalog, |_| true).into_iter().find_map(|folder| { | |
| 668 | 668 | let (_, path) = entries(folder) |
| 669 | 669 | .into_iter() |
| 670 | 670 | .find(|(id, _)| *id == identity)?; |
| ... | ... | @@ -674,18 +674,12 @@ fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { |
| 674 | 674 | |
| 675 | 675 | /// The identity of the section or group at catalog `path`. |
| 676 | 676 | fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> { |
| 677 | folders(catalog).into_iter().find_map(|folder| { | |
| 677 | folders(catalog, |_| true).into_iter().find_map(|folder| { | |
| 678 | 678 | let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; |
| 679 | 679 | Some(id) |
| 680 | 680 | }) |
| 681 | 681 | } |
| 682 | 682 | |
| 683 | /// A section or group's name, as its catalog path ends. | |
| 684 | fn name_of(path: &str) -> &str { | |
| 685 | let name = path.rsplit('/').next().unwrap_or(path); | |
| 686 | name.strip_suffix(".one").unwrap_or(name) | |
| 687 | } | |
| 688 | ||
| 689 | 683 | /// The action taking back `change` to `library`'s sections and groups, which is about to be |
| 690 | 684 | /// made; none for changes Undo does not take back, as OneNote 2010 takes back none. |
| 691 | 685 | pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option<Action> { |
| ... | ... | @@ -695,7 +689,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O |
| 695 | 689 | Structure::Rename { path, name } => Change::Rename { |
| 696 | 690 | entry: identity_at(catalog, path)?, |
| 697 | 691 | from: name.clone(), |
| 698 | to: name_of(path).to_owned(), | |
| 692 | to: entry_name(path).to_owned(), | |
| 699 | 693 | }, |
| 700 | 694 | Structure::Move { path, .. } => { |
| 701 | 695 | let entry = identity_at(catalog, path)?; |
| ... | ... | @@ -707,7 +701,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O |
| 707 | 701 | } |
| 708 | 702 | } |
| 709 | 703 | Structure::Reorder { folder, .. } => { |
| 710 | let folder = folders(catalog) | |
| 704 | let folder = folders(catalog, |_| true) | |
| 711 | 705 | .into_iter() |
| 712 | 706 | .find(|listed| listed.path == *folder)?; |
| 713 | 707 | Change::Place { |
| ... | ... | @@ -733,7 +727,7 @@ fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::S |
| 733 | 727 | let (structure, undo) = match change { |
| 734 | 728 | Change::Rename { entry, from, to } => { |
| 735 | 729 | let (_, path) = entry_of(catalog, *entry)?; |
| 736 | if name_of(&path) != from { | |
| 730 | if entry_name(&path) != from { | |
| 737 | 731 | return None; |
| 738 | 732 | } |
| 739 | 733 | ( |
| ... | ... | @@ -1450,7 +1444,7 @@ mod tests { |
| 1450 | 1444 | } |
| 1451 | 1445 | notebook.create_group("", "Group").unwrap(); |
| 1452 | 1446 | let order = |notebook: &Notebook, folder: &str| -> Vec<String> { |
| 1453 | let folder = folders(notebook.catalog()) | |
| 1447 | let folder = folders(notebook.catalog(), |_| true) | |
| 1454 | 1448 | .into_iter() |
| 1455 | 1449 | .find(|listed| listed.path == folder) |
| 1456 | 1450 | .unwrap(); |
crates/snowbound/src/unpack.rs created+235| ... | ... | @@ -0,0 +1,235 @@ |
| 1 | //! OneNote 2010's Unpack Notebook: opening a package (`.onepkg`) asks for the notebook's name, | |
| 2 | //! colour and the folder it goes in, then writes it there (`notebook::package::unpack`) and | |
| 3 | //! opens it. | |
| 4 | ||
| 5 | use crate::{State, UserEvent, art, menus::SECTION_COLORS, platform}; | |
| 6 | use accesskit::Role; | |
| 7 | use notebook::session::Notebook; | |
| 8 | use std::path::{Path, PathBuf}; | |
| 9 | use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; | |
| 10 | use winit::keyboard::NamedKey; | |
| 11 | ||
| 12 | const TITLE: &str = "Unpack Notebook"; | |
| 13 | /// The most a package may unpack to. | |
| 14 | const LIMIT: u64 = 16 << 30; | |
| 15 | ||
| 16 | /// The dialog's fields while it is open. | |
| 17 | pub struct Dialog { | |
| 18 | package: PathBuf, | |
| 19 | name: String, | |
| 20 | /// A colour picked, COLORREF; none keeps the package's. | |
| 21 | color: Option<u32>, | |
| 22 | folder: PathBuf, | |
| 23 | } | |
| 24 | ||
| 25 | fn id() -> Id { | |
| 26 | Id::ROOT.child("unpack") | |
| 27 | } | |
| 28 | ||
| 29 | fn name_field() -> Id { | |
| 30 | id().child("name") | |
| 31 | } | |
| 32 | ||
| 33 | impl State { | |
| 34 | /// Opens Unpack Notebook on the package at `package`, named for its file and going in the | |
| 35 | /// documents folder, as OneNote's starts in its notebooks folder. | |
| 36 | pub(crate) fn open_unpack(&mut self, package: &Path) { | |
| 37 | let name = package | |
| 38 | .file_stem() | |
| 39 | .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); | |
| 40 | let folder = (platform::documents_dir()) | |
| 41 | .or_else(|| package.parent().map(Path::to_owned)) | |
| 42 | .unwrap_or_default(); | |
| 43 | self.unpacking = Some(Dialog { | |
| 44 | package: package.to_owned(), | |
| 45 | name, | |
| 46 | color: None, | |
| 47 | folder, | |
| 48 | }); | |
| 49 | self.ui.open_popup(id()); | |
| 50 | self.ui.focus_all(name_field()); | |
| 51 | } | |
| 52 | ||
| 53 | /// Builds Unpack Notebook while it is open. Create, or Enter in the name, unpacks into a | |
| 54 | /// new folder of that name. | |
| 55 | pub(crate) fn unpack_dialog(&mut self) { | |
| 56 | let Some(dialog) = &mut self.unpacking else { | |
| 57 | return; | |
| 58 | }; | |
| 59 | let ui = &mut self.ui; | |
| 60 | if !ui.popup_open(id()) { | |
| 61 | self.unpacking = None; | |
| 62 | return; | |
| 63 | } | |
| 64 | let theme = ui.theme.clone(); | |
| 65 | let row = theme.font_size * 2.0; | |
| 66 | let entered = ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]) | |
| 67 | .contains(&NamedKey::Enter); | |
| 68 | ui.open_as( | |
| 69 | id(), | |
| 70 | Spec { | |
| 71 | axis: Axis::Y, | |
| 72 | size: [px(420.0), children()], | |
| 73 | fill: Some(theme.popup), | |
| 74 | border: Some(theme.chip), | |
| 75 | shadow: Some(theme.shadow), | |
| 76 | radius: 8.0, | |
| 77 | pad: [16.0, 12.0], | |
| 78 | gap: 4.0, | |
| 79 | anchor: Some(Anchor::Dialog), | |
| 80 | role: Some(Role::Dialog), | |
| 81 | ..Spec::default() | |
| 82 | }, | |
| 83 | ); | |
| 84 | if let Some(node) = ui.access(id()) { | |
| 85 | node.set_label(TITLE); | |
| 86 | } | |
| 87 | ui.leaf( | |
| 88 | "title", | |
| 89 | Spec { | |
| 90 | size: [fill(), px(row)], | |
| 91 | text: Some(TITLE), | |
| 92 | bold: true, | |
| 93 | role: Some(Role::Heading), | |
| 94 | ..Spec::default() | |
| 95 | }, | |
| 96 | ); | |
| 97 | let label = |ui: &mut ui::Ui, text: &str| { | |
| 98 | ui.leaf( | |
| 99 | text, | |
| 100 | Spec { | |
| 101 | size: [fill(), px(row * 0.8)], | |
| 102 | text: Some(text), | |
| 103 | ..Spec::default() | |
| 104 | }, | |
| 105 | ); | |
| 106 | }; | |
| 107 | label(ui, "Name:"); | |
| 108 | ui::text_field( | |
| 109 | ui, | |
| 110 | name_field(), | |
| 111 | &mut dialog.name, | |
| 112 | "", | |
| 113 | Spec { | |
| 114 | size: [fill(), px(row)], | |
| 115 | fill: Some(theme.base), | |
| 116 | border: Some(theme.accent), | |
| 117 | radius: 4.0, | |
| 118 | pad: [6.0, 0.0], | |
| 119 | ..Spec::default() | |
| 120 | }, | |
| 121 | ); | |
| 122 | if let Some(node) = ui.access(name_field()) { | |
| 123 | node.set_label("Name"); | |
| 124 | } | |
| 125 | label(ui, "Color:"); | |
| 126 | let colors = id().child("colors"); | |
| 127 | let named = (SECTION_COLORS.iter()) | |
| 128 | .find(|(color, _)| Some(*color) == dialog.color) | |
| 129 | .map_or("As packaged", |(_, name)| name); | |
| 130 | let combo = ui.id("combo"); | |
| 131 | ui::shell::combo(ui, "combo", "Color", named, 160.0, colors, true); | |
| 132 | let items: Vec<Item> = (SECTION_COLORS.iter()) | |
| 133 | .map(|&(color, text)| Item { | |
| 134 | text, | |
| 135 | icon: Some(art::SECTION), | |
| 136 | tint: Some(theme.section(crate::section_color(Some(color))).accent), | |
| 137 | current: Some(color) == dialog.color, | |
| 138 | ..Item::default() | |
| 139 | }) | |
| 140 | .collect(); | |
| 141 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | |
| 142 | if let Some(index) = ui::popup::menu(ui, colors, anchor, &items, None) { | |
| 143 | dialog.color = Some(SECTION_COLORS[index].0); | |
| 144 | } | |
| 145 | label(ui, "Path:"); | |
| 146 | let folder = dialog.folder.to_string_lossy().into_owned(); | |
| 147 | ui.leaf( | |
| 148 | "folder", | |
| 149 | Spec { | |
| 150 | flags: ui::Flags::CLIP, | |
| 151 | size: [fill(), px(row * 0.8)], | |
| 152 | text: Some(&folder), | |
| 153 | color: Some(theme.text_dim), | |
| 154 | ..Spec::default() | |
| 155 | }, | |
| 156 | ); | |
| 157 | let browse = ui::button(ui, "browse", "Browse…").clicked; | |
| 158 | let root = dialog.folder.join(dialog.name.trim()); | |
| 159 | let full = format!("Full path: {}", root.display()); | |
| 160 | ui.leaf( | |
| 161 | "full", | |
| 162 | Spec { | |
| 163 | flags: ui::Flags::CLIP, | |
| 164 | size: [fill(), px(row * 0.8)], | |
| 165 | text: Some(&full), | |
| 166 | color: Some(theme.text_dim), | |
| 167 | ..Spec::default() | |
| 168 | }, | |
| 169 | ); | |
| 170 | crate::buttons(ui); | |
| 171 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | |
| 172 | let create = ui::button(ui, "create", "Create").clicked || entered; | |
| 173 | ui.close(); | |
| 174 | ui.close(); | |
| 175 | if browse | |
| 176 | && let Some(chosen) = | |
| 177 | platform::pick_new(TITLE, dialog.name.trim(), "Choose", Some(&dialog.folder)) | |
| 178 | { | |
| 179 | if let Some(name) = chosen.file_name() { | |
| 180 | dialog.name = name.to_string_lossy().into_owned(); | |
| 181 | } | |
| 182 | if let Some(parent) = chosen.parent() { | |
| 183 | dialog.folder = parent.to_owned(); | |
| 184 | } | |
| 185 | } | |
| 186 | let name = dialog.name.trim(); | |
| 187 | let valid = | |
| 188 | !name.is_empty() && !name.contains(['/', '\\', ':']) && name != "." && name != ".."; | |
| 189 | if create && !valid { | |
| 190 | return platform::alert("Couldn't unpack the notebook", "Give the notebook a name."); | |
| 191 | } | |
| 192 | if create && root.exists() { | |
| 193 | return platform::alert( | |
| 194 | "Couldn't unpack the notebook", | |
| 195 | &format!( | |
| 196 | "{} already exists. Choose another name or path.", | |
| 197 | root.display() | |
| 198 | ), | |
| 199 | ); | |
| 200 | } | |
| 201 | if !create && !cancel { | |
| 202 | return; | |
| 203 | } | |
| 204 | let dialog = self.unpacking.take().expect("The dialog is open"); | |
| 205 | self.ui.close_popup(id()); | |
| 206 | if create { | |
| 207 | self.unpack(dialog.package, root, dialog.color); | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /// Unpacks `package` into the new folder `root` on a thread of its own, colours it | |
| 212 | /// `color` where one was picked, and opens it. | |
| 213 | fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option<u32>) { | |
| 214 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); | |
| 215 | std::thread::spawn(move || { | |
| 216 | let unpacked = (|| -> Result<String, Box<dyn std::error::Error>> { | |
| 217 | let files = notebook::package::read(&std::fs::read(&package)?, LIMIT)?; | |
| 218 | notebook::package::unpack(files, &root)?; | |
| 219 | let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); | |
| 220 | if let Some(color) = color { | |
| 221 | Notebook::open(&location, &cache)?.set_color(color)?; | |
| 222 | } | |
| 223 | Ok(location) | |
| 224 | })() | |
| 225 | .map_err(|error| error.to_string()); | |
| 226 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { | |
| 227 | match unpacked { | |
| 228 | Ok(location) => state.open_notebook(location, None), | |
| 229 | Err(error) => platform::alert("Couldn't unpack the notebook", &error), | |
| 230 | } | |
| 231 | Ok(()) | |
| 232 | }))); | |
| 233 | }); | |
| 234 | } | |
| 235 | } |
crates/snowbound/src/unread.rs created+431| ... | ... | @@ -0,0 +1,431 @@ |
| 1 | //! Unread changes, as OneNote 2010 shows them: a page another author changed since it was | |
| 2 | //! last viewed is bold in the page list, and so are its section's tab and row and the groups | |
| 3 | //! and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps | |
| 4 | //! this, and whether a notebook shows it, per user and machine in its cache, never in the | |
| 5 | //! notebook's files; this keeps it in the cache folder's `read.json`. | |
| 6 | ||
| 7 | use crate::{Library, State, UserEvent, menus, recycle}; | |
| 8 | use onestore::ExGuid; | |
| 9 | use std::{ | |
| 10 | collections::{BTreeMap, BTreeSet, HashSet}, | |
| 11 | path::{Path, PathBuf}, | |
| 12 | sync::Arc, | |
| 13 | }; | |
| 14 | ||
| 15 | /// Seconds from 1970 to 1980, where OneNote's Time32 starts. | |
| 16 | const TIME32: u64 = 315_532_800; | |
| 17 | ||
| 18 | /// What has been read, by notebook location. | |
| 19 | #[derive(Default, serde::Serialize, serde::Deserialize)] | |
| 20 | pub struct Reads { | |
| 21 | notebooks: BTreeMap<String, Kept>, | |
| 22 | #[serde(skip)] | |
| 23 | file: PathBuf, | |
| 24 | /// The page shown: its notebook, section and space, and whether Mark as Unread left it | |
| 25 | /// unread, which leaving it then keeps. | |
| 26 | #[serde(skip)] | |
| 27 | viewing: Option<(String, [u8; 16], ExGuid, bool)>, | |
| 28 | /// The notebooks whose sections were last taken in, by `Arc` address. | |
| 29 | #[serde(skip)] | |
| 30 | taken: Vec<usize>, | |
| 31 | } | |
| 32 | ||
| 33 | #[derive(Default, serde::Serialize, serde::Deserialize)] | |
| 34 | struct Kept { | |
| 35 | /// Show Unread Changes in This Notebook turned off. | |
| 36 | #[serde(default)] | |
| 37 | hidden: bool, | |
| 38 | /// By section file identity, in hexadecimal. | |
| 39 | sections: BTreeMap<String, Section>, | |
| 40 | } | |
| 41 | ||
| 42 | #[derive(Default, serde::Serialize, serde::Deserialize)] | |
| 43 | struct Section { | |
| 44 | /// Time32 up to which changes to the section's pages are known. | |
| 45 | read: u32, | |
| 46 | unread: BTreeSet<ExGuid>, | |
| 47 | } | |
| 48 | ||
| 49 | fn hex(identity: [u8; 16]) -> String { | |
| 50 | identity.iter().map(|byte| format!("{byte:02x}")).collect() | |
| 51 | } | |
| 52 | ||
| 53 | fn now() -> u32 { | |
| 54 | let unix = std::time::SystemTime::now() | |
| 55 | .duration_since(std::time::UNIX_EPOCH) | |
| 56 | .map_or(0, |since| since.as_secs()); | |
| 57 | u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) | |
| 58 | } | |
| 59 | ||
| 60 | impl Reads { | |
| 61 | /// What the cache folder `cache` keeps. | |
| 62 | pub fn load(cache: &Path) -> Self { | |
| 63 | let file = cache.join("read.json"); | |
| 64 | let reads: Self = (std::fs::read(&file).ok()) | |
| 65 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) | |
| 66 | .unwrap_or_default(); | |
| 67 | Self { file, ..reads } | |
| 68 | } | |
| 69 | ||
| 70 | fn save(&self) { | |
| 71 | let partial = self.file.with_extension("partial"); | |
| 72 | let saved = serde_json::to_vec(self) | |
| 73 | .map_err(std::io::Error::from) | |
| 74 | .and_then(|bytes| std::fs::write(&partial, bytes)) | |
| 75 | .and_then(|()| std::fs::rename(&partial, &self.file)); | |
| 76 | if let Err(error) = saved { | |
| 77 | eprintln!("Keeping what was read failed: {error}"); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | fn section(&self, notebook: &str, section: [u8; 16]) -> Option<&Section> { | |
| 82 | let kept = self.notebooks.get(notebook).filter(|kept| !kept.hidden)?; | |
| 83 | kept.sections.get(&hex(section)) | |
| 84 | } | |
| 85 | ||
| 86 | fn section_mut(&mut self, notebook: &str, section: [u8; 16]) -> &mut Section { | |
| 87 | let kept = self.notebooks.entry(notebook.to_owned()).or_default(); | |
| 88 | (kept.sections.entry(hex(section))).or_insert_with(|| Section { | |
| 89 | read: now(), | |
| 90 | unread: BTreeSet::new(), | |
| 91 | }) | |
| 92 | } | |
| 93 | ||
| 94 | /// Whether Show Unread Changes is on for the notebook at `notebook`. | |
| 95 | pub fn shown(&self, notebook: &str) -> bool { | |
| 96 | self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden) | |
| 97 | } | |
| 98 | ||
| 99 | /// The unread pages of a section. | |
| 100 | pub fn pages(&self, notebook: &str, section: [u8; 16]) -> Option<&BTreeSet<ExGuid>> { | |
| 101 | self.section(notebook, section) | |
| 102 | .map(|section| &section.unread) | |
| 103 | } | |
| 104 | ||
| 105 | /// Takes in the pages of a section another author changed, by space. | |
| 106 | fn changed( | |
| 107 | &mut self, | |
| 108 | notebook: &str, | |
| 109 | section: [u8; 16], | |
| 110 | pages: impl IntoIterator<Item = ExGuid>, | |
| 111 | ) { | |
| 112 | self.section_mut(notebook, section).unread.extend(pages); | |
| 113 | self.save(); | |
| 114 | } | |
| 115 | ||
| 116 | /// Takes in a section as its file stands, its pages with their `LastModifiedTime`: those | |
| 117 | /// changed since it was last known are unread. | |
| 118 | fn arrived( | |
| 119 | &mut self, | |
| 120 | notebook: &str, | |
| 121 | section: [u8; 16], | |
| 122 | pages: impl IntoIterator<Item = (ExGuid, Option<u32>)>, | |
| 123 | ) { | |
| 124 | let now = now(); | |
| 125 | let kept = self.section_mut(notebook, section); | |
| 126 | let read = kept.read; | |
| 127 | let mut newest = read.max(now); | |
| 128 | for (space, modified) in pages { | |
| 129 | if modified.is_some_and(|modified| modified > read) { | |
| 130 | kept.unread.insert(space); | |
| 131 | } | |
| 132 | newest = newest.max(modified.unwrap_or(0)); | |
| 133 | } | |
| 134 | kept.read = newest; | |
| 135 | self.save(); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | impl State { | |
| 140 | /// The open section's unread pages. | |
| 141 | pub(crate) fn unread_pages(&self) -> HashSet<ExGuid> { | |
| 142 | let Some(session) = &self.session else { | |
| 143 | return HashSet::new(); | |
| 144 | }; | |
| 145 | let section = session | |
| 146 | .library | |
| 147 | .section_identity(&session.tabs[session.tab].path); | |
| 148 | (section.and_then(|section| self.reads.pages(&session.library.location, section))) | |
| 149 | .map_or_else(HashSet::new, |pages| pages.iter().copied().collect()) | |
| 150 | } | |
| 151 | ||
| 152 | /// The sections of `library` holding unread pages, but the recycle bin's. | |
| 153 | fn unread_sections<'a>(&'a self, library: &'a Library) -> impl Iterator<Item = &'a str> + 'a { | |
| 154 | (menus::folders(library).into_iter()) | |
| 155 | .flat_map(|folder| &folder.sections) | |
| 156 | .filter(|section| { | |
| 157 | (self.reads.pages(&library.location, section.file_id)) | |
| 158 | .is_some_and(|pages| !pages.is_empty()) | |
| 159 | }) | |
| 160 | .map(|section| section.path.as_str()) | |
| 161 | } | |
| 162 | ||
| 163 | /// The notebooks, groups and sections holding unread pages, by `Library::key`. | |
| 164 | pub(crate) fn unread_keys(&self) -> HashSet<String> { | |
| 165 | let mut keys = HashSet::new(); | |
| 166 | for library in &self.notebooks { | |
| 167 | for mut path in self.unread_sections(library) { | |
| 168 | keys.insert(library.key(path)); | |
| 169 | while let Some((folder, _)) = path.rsplit_once('/') { | |
| 170 | keys.insert(library.key(folder)); | |
| 171 | path = folder; | |
| 172 | } | |
| 173 | keys.insert(library.key("")); | |
| 174 | } | |
| 175 | } | |
| 176 | keys | |
| 177 | } | |
| 178 | ||
| 179 | /// Whether `library` has an unread page anywhere. | |
| 180 | pub(crate) fn unread_notebook(&self, library: &Library) -> bool { | |
| 181 | self.unread_sections(library).next().is_some() | |
| 182 | } | |
| 183 | ||
| 184 | /// Follows what is read, each frame: a notebook's sections are known from when it opens, | |
| 185 | /// and the page shown turns read once left, its section known up to then. | |
| 186 | pub(crate) fn follow_reading(&mut self) { | |
| 187 | let taken: Vec<usize> = self | |
| 188 | .notebooks | |
| 189 | .iter() | |
| 190 | .map(|library| Arc::as_ptr(library) as usize) | |
| 191 | .collect(); | |
| 192 | if taken != self.reads.taken { | |
| 193 | let mut added = false; | |
| 194 | for library in &self.notebooks { | |
| 195 | for folder in menus::folders(library) { | |
| 196 | for section in &folder.sections { | |
| 197 | let kept = self | |
| 198 | .reads | |
| 199 | .notebooks | |
| 200 | .entry(library.location.clone()) | |
| 201 | .or_default(); | |
| 202 | if !kept.sections.contains_key(&hex(section.file_id)) { | |
| 203 | self.reads.section_mut(&library.location, section.file_id); | |
| 204 | added = true; | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | } | |
| 209 | if added { | |
| 210 | self.reads.save(); | |
| 211 | } | |
| 212 | self.reads.taken = taken; | |
| 213 | } | |
| 214 | let viewing = self.session.as_ref().and_then(|session| { | |
| 215 | let path = &session.tabs[session.tab].path; | |
| 216 | let section = session.library.section_identity(path)?; | |
| 217 | (session.version.is_none() && !recycle::binned(path)) | |
| 218 | .then(|| (session.library.location.clone(), section, session.space)) | |
| 219 | }); | |
| 220 | let same = |(notebook, section, space, _): &(String, [u8; 16], ExGuid, bool)| { | |
| 221 | viewing.as_ref() == Some(&(notebook.clone(), *section, *space)) | |
| 222 | }; | |
| 223 | if self.reads.viewing.as_ref().is_some_and(same) { | |
| 224 | return; | |
| 225 | } | |
| 226 | if let Some((notebook, section, space, kept)) = self.reads.viewing.take() { | |
| 227 | let left = self.reads.section_mut(&notebook, section); | |
| 228 | let removed = !kept && left.unread.remove(&space); | |
| 229 | let moved = viewing.as_ref().is_none_or(|(_, now, _)| *now != section); | |
| 230 | if moved { | |
| 231 | left.read = left.read.max(now()); | |
| 232 | } | |
| 233 | if removed || moved { | |
| 234 | self.reads.save(); | |
| 235 | } | |
| 236 | } | |
| 237 | self.reads.viewing = | |
| 238 | viewing.map(|(notebook, section, space)| (notebook, section, space, false)); | |
| 239 | } | |
| 240 | ||
| 241 | /// Pages of the open section another author changed, as its sync reports them. | |
| 242 | pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) { | |
| 243 | if let Some(session) = &self.session | |
| 244 | && let Some(section) = session | |
| 245 | .library | |
| 246 | .section_identity(&session.tabs[session.tab].path) | |
| 247 | { | |
| 248 | self.reads | |
| 249 | .changed(&session.library.location, section, spaces.iter().copied()); | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /// Sections of `library` at catalog `paths` whose files another client changed: their | |
| 254 | /// pages are read on a thread of their own, those changed since unread. | |
| 255 | pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) { | |
| 256 | let (library, proxy) = (Arc::clone(library), self.proxy.clone()); | |
| 257 | std::thread::spawn(move || { | |
| 258 | let Ok(Some(notebook)) = &library.notebook else { | |
| 259 | return; | |
| 260 | }; | |
| 261 | let read: Vec<_> = (paths.iter()) | |
| 262 | .filter(|path| !recycle::binned(path)) | |
| 263 | .filter_map(|path| { | |
| 264 | let section = library.section_identity(path)?; | |
| 265 | let image = notebook.read_section(path).ok()?; | |
| 266 | let pages = notebook::session::stored_pages(&image).ok()?; | |
| 267 | Some((section, pages)) | |
| 268 | }) | |
| 269 | .collect(); | |
| 270 | let location = library.location.clone(); | |
| 271 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { | |
| 272 | for (section, pages) in read { | |
| 273 | let pages = pages.iter().map(|page| (page.space, page.modified)); | |
| 274 | state.reads.arrived(&location, section, pages); | |
| 275 | } | |
| 276 | Ok(()) | |
| 277 | }))); | |
| 278 | }); | |
| 279 | } | |
| 280 | ||
| 281 | /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does. | |
| 282 | pub(crate) fn toggle_read(&mut self) { | |
| 283 | let Some((notebook, section, space, kept)) = self.reads.viewing.as_mut() else { | |
| 284 | return; | |
| 285 | }; | |
| 286 | let pages = &mut self.reads.notebooks.entry(notebook.clone()).or_default(); | |
| 287 | let unread = &mut (pages.sections.entry(hex(*section)).or_default()).unread; | |
| 288 | *kept = unread.insert(*space); | |
| 289 | if !*kept { | |
| 290 | unread.remove(space); | |
| 291 | } | |
| 292 | self.reads.save(); | |
| 293 | } | |
| 294 | ||
| 295 | /// Whether the page shown is unread. | |
| 296 | pub(crate) fn page_unread(&self) -> bool { | |
| 297 | self.reads | |
| 298 | .viewing | |
| 299 | .as_ref() | |
| 300 | .is_some_and(|(notebook, section, space, _)| { | |
| 301 | self.reads | |
| 302 | .pages(notebook, *section) | |
| 303 | .is_some_and(|pages| pages.contains(space)) | |
| 304 | }) | |
| 305 | } | |
| 306 | ||
| 307 | /// Mark Notebook as Read. | |
| 308 | pub(crate) fn mark_notebook_read(&mut self, library: &Library) { | |
| 309 | if let Some(kept) = self.reads.notebooks.get_mut(&library.location) { | |
| 310 | kept.sections | |
| 311 | .values_mut() | |
| 312 | .for_each(|section| section.unread.clear()); | |
| 313 | if let Some((.., kept)) = &mut self.reads.viewing { | |
| 314 | *kept = false; | |
| 315 | } | |
| 316 | self.reads.save(); | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | /// Show Unread Changes in This Notebook, on or off. | |
| 321 | pub(crate) fn toggle_unread_shown(&mut self, library: &Library) { | |
| 322 | let kept = self | |
| 323 | .reads | |
| 324 | .notebooks | |
| 325 | .entry(library.location.clone()) | |
| 326 | .or_default(); | |
| 327 | kept.hidden = !kept.hidden; | |
| 328 | self.reads.save(); | |
| 329 | } | |
| 330 | ||
| 331 | /// Next Unread: the next unread page of the open section, else the first of the next | |
| 332 | /// section, in the notebook's order, holding one. | |
| 333 | pub(crate) fn next_unread(&mut self) { | |
| 334 | let Some(session) = &self.session else { | |
| 335 | return; | |
| 336 | }; | |
| 337 | let unread = self.unread_pages(); | |
| 338 | let at = session | |
| 339 | .pages | |
| 340 | .iter() | |
| 341 | .position(|(space, ..)| *space == session.space); | |
| 342 | let after = (session.pages.iter().skip(at.map_or(0, |at| at + 1))) | |
| 343 | .find(|(space, ..)| unread.contains(space)); | |
| 344 | if let Some((space, ..)) = after { | |
| 345 | self.commands.push(crate::Command::OpenPage(*space)); | |
| 346 | return; | |
| 347 | } | |
| 348 | let library = Arc::clone(&session.library); | |
| 349 | let here = session.tabs[session.tab].path.clone(); | |
| 350 | let paths: Vec<String> = (menus::folders(&library).into_iter()) | |
| 351 | .flat_map(|folder| library.tabs(&folder.path)) | |
| 352 | .map(|tab| tab.path) | |
| 353 | .collect(); | |
| 354 | let start = paths | |
| 355 | .iter() | |
| 356 | .position(|path| *path == here) | |
| 357 | .map_or(0, |at| at + 1); | |
| 358 | let next = (paths[start..].iter().chain(&paths[..start])).find_map(|path| { | |
| 359 | let section = library.section_identity(path)?; | |
| 360 | let first = self.reads.pages(&library.location, section)?.first()?; | |
| 361 | Some((path.clone(), *first)) | |
| 362 | }); | |
| 363 | if let Some((path, space)) = next { | |
| 364 | self.last_pages.insert(library.key(&path), space); | |
| 365 | self.commands | |
| 366 | .push(crate::Command::OpenSection(library, path)); | |
| 367 | } | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | #[cfg(test)] | |
| 372 | mod tests { | |
| 373 | use super::*; | |
| 374 | ||
| 375 | fn page(n: u32, modified: Option<u32>) -> (ExGuid, Option<u32>) { | |
| 376 | ( | |
| 377 | ExGuid { | |
| 378 | guid: [n as u8; 16], | |
| 379 | n, | |
| 380 | }, | |
| 381 | modified, | |
| 382 | ) | |
| 383 | } | |
| 384 | ||
| 385 | /// A section first known has nothing unread; pages another client changed after are, | |
| 386 | /// and stay so across a reload, until read; a notebook not showing them shows none. | |
| 387 | #[test] | |
| 388 | fn pages_changed_elsewhere_stay_unread_until_read() { | |
| 389 | let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id())); | |
| 390 | std::fs::create_dir_all(&folder).unwrap(); | |
| 391 | let (notebook, section) = ("/notebook", [3; 16]); | |
| 392 | let mut reads = Reads::load(&folder); | |
| 393 | let known = reads.section_mut(notebook, section).read; | |
| 394 | reads.arrived( | |
| 395 | notebook, | |
| 396 | section, | |
| 397 | [page(1, Some(known - 10)), page(2, Some(known + 5))], | |
| 398 | ); | |
| 399 | let unread = |reads: &Reads| -> Vec<u32> { | |
| 400 | (reads.pages(notebook, section).into_iter().flatten()) | |
| 401 | .map(|space| space.n) | |
| 402 | .collect() | |
| 403 | }; | |
| 404 | assert_eq!(unread(&reads), [2]); | |
| 405 | reads.changed( | |
| 406 | notebook, | |
| 407 | section, | |
| 408 | [ExGuid { | |
| 409 | guid: [4; 16], | |
| 410 | n: 4, | |
| 411 | }], | |
| 412 | ); | |
| 413 | let mut reads = Reads::load(&folder); | |
| 414 | assert_eq!(unread(&reads), [2, 4]); | |
| 415 | // Known up to the newest change: the same page arriving again is no news. | |
| 416 | reads.section_mut(notebook, section).unread.clear(); | |
| 417 | reads.arrived(notebook, section, [page(2, Some(known + 5))]); | |
| 418 | assert!(unread(&reads).is_empty()); | |
| 419 | reads.changed( | |
| 420 | notebook, | |
| 421 | section, | |
| 422 | [ExGuid { | |
| 423 | guid: [4; 16], | |
| 424 | n: 4, | |
| 425 | }], | |
| 426 | ); | |
| 427 | reads.notebooks.get_mut(notebook).unwrap().hidden = true; | |
| 428 | assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); | |
| 429 | std::fs::remove_dir_all(&folder).unwrap(); | |
| 430 | } | |
| 431 | } |
crates/snowbound/src/update.rs+254-31| ... | ... | @@ -73,7 +73,7 @@ pub struct Version { |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 75 | impl Version { |
| 76 | fn parse(name: &str) -> Option<Self> { | |
| 76 | pub(crate) fn parse(name: &str) -> Option<Self> { | |
| 77 | 77 | let (date, revision) = name.split_once("-r")?; |
| 78 | 78 | let digits = |range: std::ops::Range<usize>| { |
| 79 | 79 | date.get(range) |
| ... | ... | @@ -120,6 +120,64 @@ pub fn describe_running() -> String { |
| 120 | 120 | struct Build { |
| 121 | 121 | version: String, |
| 122 | 122 | archives: HashMap<String, Archive>, |
| 123 | /// Every change since the first published build, oldest first; builds before these | |
| 124 | /// were listed have none. | |
| 125 | #[serde(default)] | |
| 126 | changes: Vec<Change>, | |
| 127 | } | |
| 128 | ||
| 129 | /// One feature, bug fix or other change a build brings, as `tools/release.py` lists them. | |
| 130 | #[derive(Clone, Debug, Deserialize, PartialEq)] | |
| 131 | pub struct Change { | |
| 132 | /// The version of the commit that made it. | |
| 133 | version: String, | |
| 134 | pub kind: Kind, | |
| 135 | pub title: String, | |
| 136 | } | |
| 137 | ||
| 138 | #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] | |
| 139 | #[serde(rename_all = "lowercase")] | |
| 140 | pub enum Kind { | |
| 141 | Feature, | |
| 142 | Fix, | |
| 143 | #[serde(other)] | |
| 144 | Other, | |
| 145 | } | |
| 146 | ||
| 147 | impl Kind { | |
| 148 | /// The kind's heading in a list of changes. | |
| 149 | pub fn heading(self) -> &'static str { | |
| 150 | match self { | |
| 151 | Kind::Feature => "Features", | |
| 152 | Kind::Fix => "Bug fixes", | |
| 153 | Kind::Other => "Other changes", | |
| 154 | } | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | /// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when | |
| 159 | /// there are none. | |
| 160 | pub fn summary(changes: &[Change]) -> Option<String> { | |
| 161 | let parts: Vec<String> = [ | |
| 162 | (Kind::Feature, "feature", "features"), | |
| 163 | (Kind::Fix, "bug fix", "bug fixes"), | |
| 164 | (Kind::Other, "other change", "other changes"), | |
| 165 | ] | |
| 166 | .into_iter() | |
| 167 | .filter_map(|(kind, one, many)| { | |
| 168 | match changes.iter().filter(|change| change.kind == kind).count() { | |
| 169 | 0 => None, | |
| 170 | 1 => Some(format!("1 {one}")), | |
| 171 | count => Some(format!("{count} {many}")), | |
| 172 | } | |
| 173 | }) | |
| 174 | .collect(); | |
| 175 | match parts.as_slice() { | |
| 176 | [] => None, | |
| 177 | [one] => Some(one.clone()), | |
| 178 | [first, second] => Some(format!("{first} and {second}")), | |
| 179 | [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))), | |
| 180 | } | |
| 123 | 181 | } |
| 124 | 182 | |
| 125 | 183 | #[derive(Clone, Debug, Deserialize)] |
| ... | ... | @@ -169,25 +227,35 @@ fn newer( |
| 169 | 227 | } |
| 170 | 228 | |
| 171 | 229 | /// `platform`'s archive in the build `build.json` describes, once its signature holds and |
| 172 | /// it describes `version`. | |
| 230 | /// it describes `version`, and its changes since `running`, features first. | |
| 173 | 231 | fn archive( |
| 174 | 232 | key: &[u8], |
| 175 | 233 | build: &[u8], |
| 176 | 234 | signature: &[u8], |
| 177 | 235 | version: &Version, |
| 178 | 236 | platform: &str, |
| 179 | ) -> Result<Archive, String> { | |
| 237 | running: Option<&Version>, | |
| 238 | ) -> Result<(Archive, Vec<Change>), String> { | |
| 180 | 239 | verify(key, build, &String::from_utf8_lossy(signature))?; |
| 181 | let build: Build = | |
| 240 | let mut build: Build = | |
| 182 | 241 | serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; |
| 183 | 242 | if Version::parse(&build.version).as_ref() != Some(version) { |
| 184 | 243 | return Err(format!("build.json describes {}", build.version)); |
| 185 | 244 | } |
| 186 | build | |
| 245 | let archive = build | |
| 187 | 246 | .archives |
| 188 | .get(platform) | |
| 189 | .cloned() | |
| 190 | .ok_or_else(|| format!("{} has no {platform} archive", version.name())) | |
| 247 | .remove(platform) | |
| 248 | .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?; | |
| 249 | let mut changes: Vec<Change> = build | |
| 250 | .changes | |
| 251 | .into_iter() | |
| 252 | .filter(|change| { | |
| 253 | Version::parse(&change.version) | |
| 254 | .is_some_and(|made| running.is_none_or(|running| made > *running)) | |
| 255 | }) | |
| 256 | .collect(); | |
| 257 | changes.sort_by_key(|change| change.kind); | |
| 258 | Ok((archive, changes)) | |
| 191 | 259 | } |
| 192 | 260 | |
| 193 | 261 | fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { |
| ... | ... | @@ -302,10 +370,11 @@ pub enum Status { |
| 302 | 370 | Idle, |
| 303 | 371 | UpToDate, |
| 304 | 372 | Downloading(Version), |
| 305 | /// Verified and unpacked beside the install, waiting for Restart to Update. | |
| 306 | Ready(Version, PathBuf), | |
| 373 | /// Verified and unpacked beside the install, waiting for Restart to Update, with what it | |
| 374 | /// changes. | |
| 375 | Ready(Version, PathBuf, Vec<Change>), | |
| 307 | 376 | /// Newer than this build, which can't install it itself: its folder has the download. |
| 308 | Available(Version), | |
| 377 | Available(Version, Vec<Change>), | |
| 309 | 378 | Failed(&'static str), |
| 310 | 379 | } |
| 311 | 380 | |
| ... | ... | @@ -326,6 +395,7 @@ fn check( |
| 326 | 395 | progress: &dyn Fn(Status), |
| 327 | 396 | ) -> Status { |
| 328 | 397 | let platform = platform(); |
| 398 | let since = running; | |
| 329 | 399 | // Under Rosetta the native build of the same version is still the update. |
| 330 | 400 | let running = running.filter(|_| !translated()); |
| 331 | 401 | let fetch = |path: &str, limit| { |
| ... | ... | @@ -346,15 +416,16 @@ fn check( |
| 346 | 416 | }; |
| 347 | 417 | let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; |
| 348 | 418 | let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; |
| 349 | let archive = archive(key, &build, &signature, &version, &platform).map_err(unverified)?; | |
| 419 | let (archive, changes) = | |
| 420 | archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?; | |
| 350 | 421 | let Some(folder) = install.and_then(staging) else { |
| 351 | return Ok(Status::Available(version)); | |
| 422 | return Ok(Status::Available(version, changes)); | |
| 352 | 423 | }; |
| 353 | 424 | let item = staged(&folder); |
| 354 | 425 | if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name()) |
| 355 | 426 | && item.exists() |
| 356 | 427 | { |
| 357 | return Ok(Status::Ready(version, item)); | |
| 428 | return Ok(Status::Ready(version, item, changes)); | |
| 358 | 429 | } |
| 359 | 430 | progress(Status::Downloading(version.clone())); |
| 360 | 431 | let bytes = fetch( |
| ... | ... | @@ -363,10 +434,10 @@ fn check( |
| 363 | 434 | )?; |
| 364 | 435 | check_archive(key, &archive, &bytes).map_err(unverified)?; |
| 365 | 436 | Ok(match stage(&bytes, &folder, &version) { |
| 366 | Ok(item) => Status::Ready(version, item), | |
| 437 | Ok(item) => Status::Ready(version, item, changes), | |
| 367 | 438 | Err(error) => { |
| 368 | 439 | eprintln!("Cannot stage the update in {}: {error}", folder.display()); |
| 369 | Status::Available(version) | |
| 440 | Status::Available(version, changes) | |
| 370 | 441 | } |
| 371 | 442 | }) |
| 372 | 443 | })(); |
| ... | ... | @@ -410,6 +481,8 @@ pub struct Updates { |
| 410 | 481 | thread: std::thread::Thread, |
| 411 | 482 | /// Restart to Update was chosen: swap the staged build in once the app quits. |
| 412 | 483 | restart: bool, |
| 484 | /// The sync popup lists what the update changes. | |
| 485 | pub(crate) changes_listed: bool, | |
| 413 | 486 | } |
| 414 | 487 | |
| 415 | 488 | impl Updates { |
| ... | ... | @@ -474,6 +547,7 @@ impl Updates { |
| 474 | 547 | shared, |
| 475 | 548 | thread, |
| 476 | 549 | restart: false, |
| 550 | changes_listed: false, | |
| 477 | 551 | } |
| 478 | 552 | } |
| 479 | 553 | |
| ... | ... | @@ -499,7 +573,7 @@ impl Updates { |
| 499 | 573 | /// The staged build to swap in, once Restart to Update has quit the app. |
| 500 | 574 | pub fn restarting(&self) -> Option<PathBuf> { |
| 501 | 575 | match self.status() { |
| 502 | Status::Ready(_, staged) if self.restart => Some(staged), | |
| 576 | Status::Ready(_, staged, _) if self.restart => Some(staged), | |
| 503 | 577 | _ => None, |
| 504 | 578 | } |
| 505 | 579 | } |
| ... | ... | @@ -518,20 +592,26 @@ impl State { |
| 518 | 592 | return; |
| 519 | 593 | }; |
| 520 | 594 | match status { |
| 521 | Status::Ready(version, _) => { | |
| 595 | Status::Ready(version, _, changes) => { | |
| 522 | 596 | if platform::confirm( |
| 523 | 597 | "Update ready", |
| 524 | &format!("Snowbound {version} is ready to install."), | |
| 598 | &described( | |
| 599 | format!("Snowbound {version} is ready to install."), | |
| 600 | &changes, | |
| 601 | ), | |
| 525 | 602 | "Later", |
| 526 | 603 | "Restart to Update", |
| 527 | 604 | ) { |
| 528 | 605 | self.restart_to_update(); |
| 529 | 606 | } |
| 530 | 607 | } |
| 531 | Status::Available(version) => { | |
| 608 | Status::Available(version, changes) => { | |
| 532 | 609 | if platform::confirm( |
| 533 | 610 | "Update available", |
| 534 | &format!("Download Snowbound {version} from its build folder."), | |
| 611 | &described( | |
| 612 | format!("Download Snowbound {version} from its build folder."), | |
| 613 | &changes, | |
| 614 | ), | |
| 535 | 615 | "Later", |
| 536 | 616 | "Open Build Folder", |
| 537 | 617 | ) { |
| ... | ... | @@ -548,6 +628,28 @@ impl State { |
| 548 | 628 | } |
| 549 | 629 | } |
| 550 | 630 | |
| 631 | /// `lead`, then what `changes` amount to and the first dozen of their titles under their | |
| 632 | /// kinds, as a dialog's detail. | |
| 633 | fn described(lead: String, changes: &[Change]) -> String { | |
| 634 | const LISTED: usize = 12; | |
| 635 | let Some(summary) = summary(changes) else { | |
| 636 | return lead; | |
| 637 | }; | |
| 638 | let mut detail = format!("{lead} It brings {summary}.\n"); | |
| 639 | let mut kind = None; | |
| 640 | for change in changes.iter().take(LISTED) { | |
| 641 | if kind != Some(change.kind) { | |
| 642 | kind = Some(change.kind); | |
| 643 | detail += &format!("\n{}\n", change.kind.heading()); | |
| 644 | } | |
| 645 | detail += &format!("• {}\n", change.title); | |
| 646 | } | |
| 647 | if changes.len() > LISTED { | |
| 648 | detail += &format!("and {} more\n", changes.len() - LISTED); | |
| 649 | } | |
| 650 | detail.trim_end().to_owned() | |
| 651 | } | |
| 652 | ||
| 551 | 653 | /// Opens the folder a build is published in. |
| 552 | 654 | pub fn show_build(version: &Version) { |
| 553 | 655 | platform::reveal(format!("{BASE}{}", version.folder())); |
| ... | ... | @@ -696,6 +798,18 @@ mod tests { |
| 696 | 798 | assert!(newer(b"<html>", "macos-aarch64", None).is_err()); |
| 697 | 799 | } |
| 698 | 800 | |
| 801 | /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped. | |
| 802 | fn changes() -> serde_json::Value { | |
| 803 | serde_json::json!([ | |
| 804 | {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"}, | |
| 805 | {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"}, | |
| 806 | {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"}, | |
| 807 | {"version": "2026-09-29-r9", "kind": "release", "title": "Stable download names"}, | |
| 808 | {"version": "2026-09-29-r10", "kind": "feature", "title": "Styles and themes"}, | |
| 809 | {"version": "someday", "kind": "feature", "title": "Unversioned"}, | |
| 810 | ]) | |
| 811 | } | |
| 812 | ||
| 699 | 813 | /// A build.json for `archive`'s bytes under `platform`, with its signature. |
| 700 | 814 | fn publish( |
| 701 | 815 | pair: &Ed25519KeyPair, |
| ... | ... | @@ -708,6 +822,7 @@ mod tests { |
| 708 | 822 | let build = serde_json::to_vec_pretty(&serde_json::json!({ |
| 709 | 823 | "version": name, |
| 710 | 824 | "commit": "0123456789abcdef", |
| 825 | "changes": changes(), | |
| 711 | 826 | "archives": {platform: { |
| 712 | 827 | "file": file, |
| 713 | 828 | "size": bytes.len(), |
| ... | ... | @@ -728,35 +843,37 @@ mod tests { |
| 728 | 843 | let bytes = b"an archive".to_vec(); |
| 729 | 844 | let (build, signature) = |
| 730 | 845 | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); |
| 731 | let found = archive(key, &build, &signature, &tenth, "linux-x86_64").unwrap(); | |
| 846 | let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); | |
| 732 | 847 | check_archive(key, &found, &bytes).unwrap(); |
| 733 | 848 | |
| 734 | 849 | let mut tampered = build.clone(); |
| 735 | 850 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); |
| 736 | 851 | tampered[at] = b'b'; |
| 737 | assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64").is_err()); | |
| 852 | assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err()); | |
| 738 | 853 | assert!( |
| 739 | 854 | archive( |
| 740 | 855 | generate().public_key().as_ref(), |
| 741 | 856 | &build, |
| 742 | 857 | &signature, |
| 743 | 858 | &tenth, |
| 744 | "linux-x86_64" | |
| 859 | "linux-x86_64", | |
| 860 | None | |
| 745 | 861 | ) |
| 746 | 862 | .is_err() |
| 747 | 863 | ); |
| 748 | assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64").is_err()); | |
| 864 | assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64", None).is_err()); | |
| 749 | 865 | assert!( |
| 750 | 866 | archive( |
| 751 | 867 | key, |
| 752 | 868 | &build, |
| 753 | 869 | &signature, |
| 754 | 870 | &version("2026-09-29-r11"), |
| 755 | "linux-x86_64" | |
| 871 | "linux-x86_64", | |
| 872 | None | |
| 756 | 873 | ) |
| 757 | 874 | .is_err() |
| 758 | 875 | ); |
| 759 | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64").is_err()); | |
| 876 | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); | |
| 760 | 877 | |
| 761 | 878 | assert!( |
| 762 | 879 | check_archive(key, &found, b"an archivf") |
| ... | ... | @@ -780,6 +897,98 @@ mod tests { |
| 780 | 897 | ); |
| 781 | 898 | } |
| 782 | 899 | |
| 900 | fn change(kind: Kind, title: &str) -> Change { | |
| 901 | Change { | |
| 902 | version: "2026-09-29-r10".to_owned(), | |
| 903 | kind, | |
| 904 | title: title.to_owned(), | |
| 905 | } | |
| 906 | } | |
| 907 | ||
| 908 | #[test] | |
| 909 | fn summaries_name_each_kind_with_plurals_and_list_grammar() { | |
| 910 | let fix = || change(Kind::Fix, "A fix"); | |
| 911 | let feature = || change(Kind::Feature, "A feature"); | |
| 912 | let other = || change(Kind::Other, "Another change"); | |
| 913 | assert_eq!(summary(&[]), None); | |
| 914 | assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix"); | |
| 915 | assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes"); | |
| 916 | assert_eq!( | |
| 917 | summary(&[fix(), feature(), fix()]).unwrap(), | |
| 918 | "1 feature and 2 bug fixes" | |
| 919 | ); | |
| 920 | let mut all = vec![feature(), feature(), feature(), other(), other()]; | |
| 921 | all.extend(std::iter::repeat_with(fix).take(5)); | |
| 922 | assert_eq!( | |
| 923 | summary(&all).unwrap(), | |
| 924 | "3 features, 5 bug fixes, and 2 other changes" | |
| 925 | ); | |
| 926 | assert_eq!( | |
| 927 | described("Ready.".to_owned(), &[feature(), fix(), fix()]), | |
| 928 | "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix" | |
| 929 | ); | |
| 930 | assert_eq!(described("Ready.".to_owned(), &[]), "Ready."); | |
| 931 | } | |
| 932 | ||
| 933 | /// A build lists what every build since the first brought; a check sums those newer than | |
| 934 | /// the running build, skipped releases included, features first, and an unknown kind is | |
| 935 | /// another change. | |
| 936 | #[test] | |
| 937 | fn changes_sum_across_skipped_releases() { | |
| 938 | let pair = generate(); | |
| 939 | let key = pair.public_key().as_ref(); | |
| 940 | let tenth = version("2026-09-29-r10"); | |
| 941 | let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a"); | |
| 942 | let since = |running: Option<&str>| { | |
| 943 | let running = running.map(version); | |
| 944 | let (_, changes) = archive( | |
| 945 | key, | |
| 946 | &build, | |
| 947 | &signature, | |
| 948 | &tenth, | |
| 949 | "linux-x86_64", | |
| 950 | running.as_ref(), | |
| 951 | ) | |
| 952 | .unwrap(); | |
| 953 | changes | |
| 954 | .into_iter() | |
| 955 | .map(|change| (change.kind, change.title)) | |
| 956 | .collect::<Vec<_>>() | |
| 957 | }; | |
| 958 | assert_eq!( | |
| 959 | since(Some("2026-09-29-r9")), | |
| 960 | [(Kind::Feature, "Styles and themes".to_owned())] | |
| 961 | ); | |
| 962 | assert_eq!( | |
| 963 | since(Some("2026-09-29-r7")), | |
| 964 | [ | |
| 965 | (Kind::Feature, "Pinch zoom".to_owned()), | |
| 966 | (Kind::Feature, "Styles and themes".to_owned()), | |
| 967 | (Kind::Fix, "Pasted pictures keep their size".to_owned()), | |
| 968 | (Kind::Other, "Stable download names".to_owned()), | |
| 969 | ] | |
| 970 | ); | |
| 971 | assert_eq!(since(Some("2026-09-29-r10")), []); | |
| 972 | assert_eq!(since(None).len(), 5); | |
| 973 | ||
| 974 | // Builds published before changes were listed still read, as builds listing them do | |
| 975 | // for clients that predate them. | |
| 976 | let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": { | |
| 977 | "file": "a", "size": 1, "sha256": "", "signature": "", | |
| 978 | }}}); | |
| 979 | let old = serde_json::to_vec(&old).unwrap(); | |
| 980 | let signature = hex(pair.sign(&old).as_ref()).into_bytes(); | |
| 981 | let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap(); | |
| 982 | assert_eq!(changes, []); | |
| 983 | #[derive(Deserialize)] | |
| 984 | #[allow(dead_code)] | |
| 985 | struct Earlier { | |
| 986 | version: String, | |
| 987 | archives: HashMap<String, Archive>, | |
| 988 | } | |
| 989 | assert!(serde_json::from_slice::<Earlier>(&build).is_ok()); | |
| 990 | } | |
| 991 | ||
| 783 | 992 | /// An archive as `tools/release.py` packs this platform's, holding `marker`. |
| 784 | 993 | fn pack(folder: &Path, marker: &str) -> Vec<u8> { |
| 785 | 994 | if !cfg!(target_os = "macos") { |
| ... | ... | @@ -856,10 +1065,14 @@ mod tests { |
| 856 | 1065 | let status = check(&fetch, &key, Some(&running), Some(&install), &|status| { |
| 857 | 1066 | progress.lock().unwrap().push(status) |
| 858 | 1067 | }); |
| 859 | let Status::Ready(found, item) = status else { | |
| 1068 | let Status::Ready(found, item, listed) = status else { | |
| 860 | 1069 | panic!("{status:?}"); |
| 861 | 1070 | }; |
| 862 | 1071 | assert_eq!(found, version("2026-09-29-r10")); |
| 1072 | assert_eq!( | |
| 1073 | summary(&listed).unwrap(), | |
| 1074 | "2 features, 2 bug fixes, and 1 other change" | |
| 1075 | ); | |
| 863 | 1076 | assert_eq!( |
| 864 | 1077 | *progress.lock().unwrap(), |
| 865 | 1078 | [Status::Downloading(found.clone())] |
| ... | ... | @@ -869,7 +1082,10 @@ mod tests { |
| 869 | 1082 | |
| 870 | 1083 | fetched.lock().unwrap().clear(); |
| 871 | 1084 | let again = check(&fetch, &key, Some(&running), Some(&install), &|_| {}); |
| 872 | assert_eq!(again, Status::Ready(found.clone(), item.clone())); | |
| 1085 | assert_eq!( | |
| 1086 | again, | |
| 1087 | Status::Ready(found.clone(), item.clone(), listed.clone()) | |
| 1088 | ); | |
| 873 | 1089 | assert!( |
| 874 | 1090 | !fetched |
| 875 | 1091 | .lock() |
| ... | ... | @@ -884,7 +1100,7 @@ mod tests { |
| 884 | 1100 | ); |
| 885 | 1101 | assert_eq!( |
| 886 | 1102 | check(&fetch, &key, Some(&running), None, &|_| {}), |
| 887 | Status::Available(found.clone()) | |
| 1103 | Status::Available(found.clone(), listed.clone()) | |
| 888 | 1104 | ); |
| 889 | 1105 | assert_eq!( |
| 890 | 1106 | check( |
| ... | ... | @@ -955,6 +1171,13 @@ mod tests { |
| 955 | 1171 | fn the_published_build_installs() { |
| 956 | 1172 | let folder = scratch("published"); |
| 957 | 1173 | let install = staged(&folder); |
| 1174 | let placeholder = pack(&folder, "old"); | |
| 1175 | let unpacked = stage( | |
| 1176 | &placeholder, | |
| 1177 | &folder.join("unpacked"), | |
| 1178 | &version("2000-01-01-r1"), | |
| 1179 | ); | |
| 1180 | std::fs::rename(unpacked.unwrap(), &install).unwrap(); | |
| 958 | 1181 | let old = version("2000-01-01-r1"); |
| 959 | 1182 | let status = check( |
| 960 | 1183 | &download, |
| ... | ... | @@ -963,7 +1186,7 @@ mod tests { |
| 963 | 1186 | Some(&install), |
| 964 | 1187 | &|_| {}, |
| 965 | 1188 | ); |
| 966 | let Status::Ready(found, staged) = status else { | |
| 1189 | let Status::Ready(found, staged, _) = status else { | |
| 967 | 1190 | panic!("{status:?}"); |
| 968 | 1191 | }; |
| 969 | 1192 | apply(&staged, &install).unwrap(); |
crates/snowbound/src/windows.rs+103-15| ... | ... | @@ -51,6 +51,8 @@ static PREVIOUS: AtomicIsize = AtomicIsize::new(0); |
| 51 | 51 | static COMPOSED: AtomicBool = AtomicBool::new(false); |
| 52 | 52 | /// The window's scale factor, as `f32` bits, for hit-testing outside a frame. |
| 53 | 53 | static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000); |
| 54 | /// The window's smallest client size in points, as `f32` bits. | |
| 55 | static MIN_SIZE: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)]; | |
| 54 | 56 | /// The caption button under the pointer, by its hit-test code: as the system reports it |
| 55 | 57 | /// over Mica, where the system runs them, and as the row saw it for the close button, |
| 56 | 58 | /// whose glyph turns white on red. |
| ... | ... | @@ -219,7 +221,7 @@ fn offer_to_open() { |
| 219 | 221 | None, |
| 220 | 222 | &format!("\"{executable}\" \"%1\""), |
| 221 | 223 | ); |
| 222 | for extension in [".one", ".onetoc2"] { | |
| 224 | for extension in [".one", ".onetoc2", ".onepkg"] { | |
| 223 | 225 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); |
| 224 | 226 | } |
| 225 | 227 | } |
| ... | ... | @@ -259,6 +261,46 @@ fn reframe(hwnd: HWND) { |
| 259 | 261 | unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) }; |
| 260 | 262 | } |
| 261 | 263 | |
| 264 | /// Keeps the window's client area at least `size` points, widening it now if narrower. | |
| 265 | /// winit's `set_min_inner_size` would size the window to its client area with the caption | |
| 266 | /// the row took over, growing it by the caption's height each time. | |
| 267 | pub fn set_min_size(window: &Window, size: [f32; 2]) { | |
| 268 | for (bound, side) in MIN_SIZE.iter().zip(size) { | |
| 269 | bound.store(side.to_bits(), Ordering::Relaxed); | |
| 270 | } | |
| 271 | let hwnd = hwnd(window); | |
| 272 | let mut current = RECT::default(); | |
| 273 | unsafe { wm::GetWindowRect(hwnd, &mut current) }; | |
| 274 | let [width, height] = outer(hwnd, min_size()); | |
| 275 | let (now, high) = (current.right - current.left, current.bottom - current.top); | |
| 276 | if now < width || high < height { | |
| 277 | let flags = wm::SWP_NOMOVE | wm::SWP_NOZORDER | wm::SWP_NOACTIVATE; | |
| 278 | let [width, height] = [now.max(width), high.max(height)]; | |
| 279 | unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, width, height, flags) }; | |
| 280 | } | |
| 281 | } | |
| 282 | ||
| 283 | /// The smallest client size in device pixels. | |
| 284 | fn min_size() -> [i32; 2] { | |
| 285 | let scale = f32::from_bits(SCALE.load(Ordering::Relaxed)); | |
| 286 | MIN_SIZE | |
| 287 | .each_ref() | |
| 288 | .map(|side| (f32::from_bits(side.load(Ordering::Relaxed)) * scale).ceil() as i32) | |
| 289 | } | |
| 290 | ||
| 291 | /// The window size whose client area is `client`, with the window's frame as it is now. | |
| 292 | fn outer(hwnd: HWND, client: [i32; 2]) -> [i32; 2] { | |
| 293 | let (mut window, mut inner) = (RECT::default(), RECT::default()); | |
| 294 | unsafe { | |
| 295 | wm::GetWindowRect(hwnd, &mut window); | |
| 296 | wm::GetClientRect(hwnd, &mut inner); | |
| 297 | } | |
| 298 | [ | |
| 299 | client[0] + (window.right - window.left) - inner.right, | |
| 300 | client[1] + (window.bottom - window.top) - inner.bottom, | |
| 301 | ] | |
| 302 | } | |
| 303 | ||
| 262 | 304 | fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool { |
| 263 | 305 | unsafe { |
| 264 | 306 | Dwm::DwmSetWindowAttribute( |
| ... | ... | @@ -292,6 +334,14 @@ unsafe extern "system" fn frame_procedure( |
| 292 | 334 | let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); |
| 293 | 335 | wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam) |
| 294 | 336 | }; |
| 337 | if message == wm::WM_GETMINMAXINFO { | |
| 338 | let result = previous(message, wparam, lparam); | |
| 339 | let info = unsafe { &mut *(lparam as *mut wm::MINMAXINFO) }; | |
| 340 | let [width, height] = outer(hwnd, min_size()); | |
| 341 | let least = &mut info.ptMinTrackSize; | |
| 342 | (least.x, least.y) = (least.x.max(width), least.y.max(height)); | |
| 343 | return result; | |
| 344 | } | |
| 295 | 345 | match message { |
| 296 | 346 | // Accent colours and transparency effects reach the window only as settings. |
| 297 | 347 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { |
| ... | ... | @@ -652,23 +702,58 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { |
| 652 | 702 | }) |
| 653 | 703 | } |
| 654 | 704 | |
| 655 | /// The theme over the window's material. Over Windows 7's glass, fields and tool buttons | |
| 656 | /// take white faces, as Internet Explorer's do there, so their text and icons stay legible, | |
| 657 | /// and the sidebar stays opaque. | |
| 658 | pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { | |
| 659 | let sidebar = theme.sidebar; | |
| 660 | let theme = theme.over_backdrop(); | |
| 661 | if caption() != Caption::Glass || eleven() { | |
| 662 | return theme; | |
| 705 | /// The theme over the window's material. Aero glass takes whatever lies behind the window | |
| 706 | /// and any colour the user picks, so on Windows 7 the toolbar keeps opaque faces under its | |
| 707 | /// icons, as `SNOWBOUND_W7_CHROME` picks: `bar`, Explorer's opaque command bar under the glass | |
| 708 | /// frame; `pills`, a face per group of tools over the glass; `frost`, the strip see-through | |
| 709 | /// enough to tint it; or `tint`, one panel of tools in the glass's colour, and `tint-tiles`, | |
| 710 | /// a tile per group. | |
| 711 | pub fn over_backdrop(theme: ui::Theme, appearance: Theme) -> ui::Theme { | |
| 712 | if version() >= (6, 2, 0) { | |
| 713 | return theme.over_backdrop(); | |
| 663 | 714 | } |
| 664 | ui::Theme { | |
| 665 | sidebar, | |
| 666 | base: [1.0, 1.0, 1.0, 0.8], | |
| 667 | tool: [1.0, 1.0, 1.0, 0.35], | |
| 668 | ..theme | |
| 715 | match std::env::var("SNOWBOUND_W7_CHROME").as_deref() { | |
| 716 | Ok("pills") => ui::Theme { | |
| 717 | strip: [0.0; 4], | |
| 718 | tool: theme.base, | |
| 719 | ..theme | |
| 720 | }, | |
| 721 | Ok("frost") => ui::Theme { | |
| 722 | strip: [theme.strip[0], theme.strip[1], theme.strip[2], 0.85], | |
| 723 | ..theme | |
| 724 | }, | |
| 725 | Ok(chrome @ ("tint" | "tint-tiles")) => ui::Theme { | |
| 726 | strip: [0.0; 4], | |
| 727 | tool: glass_tint(appearance == Theme::Dark), | |
| 728 | tool_panel: chrome == "tint", | |
| 729 | chip: [0.0, 0.0, 0.0, 0.35], | |
| 730 | ..theme | |
| 731 | }, | |
| 732 | _ => theme, | |
| 669 | 733 | } |
| 670 | 734 | } |
| 671 | 735 | |
| 736 | /// The colour Windows 7 tints its glass with, at a lightness text keeps its contrast on, as | |
| 737 | /// Mica takes the wallpaper's: more of it as the colour's intensity rises. | |
| 738 | fn glass_tint(dark: bool) -> [f32; 4] { | |
| 739 | let (mut color, mut opaque) = (0u32, 0); | |
| 740 | unsafe { Dwm::DwmGetColorizationColor(&mut color, &mut opaque) }; | |
| 741 | let [blue, green, red, intensity] = color.to_le_bytes(); | |
| 742 | let [high, low] = [red.max(green).max(blue), red.min(green).min(blue)].map(f32::from); | |
| 743 | let lightness = (high + low) / 510.0; | |
| 744 | let saturation = if high == low { | |
| 745 | 0.0 | |
| 746 | } else { | |
| 747 | (high - low) / 255.0 / (1.0 - (2.0 * lightness - 1.0).abs()) | |
| 748 | }; | |
| 749 | let strength = 0.85 * (0.6 + 0.4 * f32::from(intensity) / 255.0); | |
| 750 | draw::hsl( | |
| 751 | draw::hue(draw::srgb(red, green, blue)), | |
| 752 | saturation * strength, | |
| 753 | if dark { 0.22 } else { 0.84 }, | |
| 754 | ) | |
| 755 | } | |
| 756 | ||
| 672 | 757 | /// None: the kit's own menus. |
| 673 | 758 | pub fn menu(_: Theme) -> Option<ui::Menu> { |
| 674 | 759 | None |
| ... | ... | @@ -1402,7 +1487,10 @@ pub fn edit_date( |
| 1402 | 1487 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1403 | 1488 | pick( |
| 1404 | 1489 | title, |
| 1405 | &[("OneNote notebooks and sections", "*.onetoc2;*.one")], | |
| 1490 | &[( | |
| 1491 | "OneNote notebooks, sections and packages", | |
| 1492 | "*.onetoc2;*.one;*.onepkg", | |
| 1493 | )], | |
| 1406 | 1494 | None, |
| 1407 | 1495 | false, |
| 1408 | 1496 | ) |
crates/snowbound/tests/replay.rs+98| ... | ... | @@ -252,3 +252,101 @@ fn undo_walks_back_through_new_pages_and_their_titles() { |
| 252 | 252 | assert_eq!(tabs, expected, "{tree}"); |
| 253 | 253 | } |
| 254 | 254 | } |
| 255 | ||
| 256 | /// The search box's results in `tree`, one a line. | |
| 257 | fn search_results(tree: &str) -> Vec<&str> { | |
| 258 | tree.lines() | |
| 259 | .skip_while(|line| line.trim() != r#"Dialog "Search""#) | |
| 260 | .filter(|line| line.trim_start().starts_with("ListBoxOption")) | |
| 261 | .collect() | |
| 262 | } | |
| 263 | ||
| 264 | /// Steps typing `query` into the search box, then writing the tree as `name`. | |
| 265 | fn search(query: &str, name: &str) -> Vec<String> { | |
| 266 | ["modifiers command", "key e", "modifiers"] | |
| 267 | .into_iter() | |
| 268 | .map(String::from) | |
| 269 | .chain([ | |
| 270 | format!("type {query}"), | |
| 271 | "wait 2500".into(), | |
| 272 | format!("accessibility {name}"), | |
| 273 | ]) | |
| 274 | .collect() | |
| 275 | } | |
| 276 | ||
| 277 | /// Steps opening the page versions notebook's first version and its bar's menu. | |
| 278 | fn version_menu() -> Vec<String> { | |
| 279 | let mut steps: Vec<String> = ["modifiers command shift", "key p", "modifiers"] | |
| 280 | .into_iter() | |
| 281 | .chain(["type Page Versions", "wait 300", "key Enter", "wait 1000"]) | |
| 282 | .map(String::from) | |
| 283 | .collect(); | |
| 284 | // The version row under the page, then the yellow bar above the version. | |
| 285 | for point in ["1003 114", "177 94"] { | |
| 286 | steps.extend([format!("move {point}"), "press".into(), "release".into()]); | |
| 287 | steps.push("wait 1200".into()); | |
| 288 | } | |
| 289 | steps | |
| 290 | } | |
| 291 | ||
| 292 | fn run(scratch: &Scratch, notebook: &Path, steps: &[String]) -> Vec<String> { | |
| 293 | let steps: Vec<&str> = steps.iter().map(String::as_str).collect(); | |
| 294 | replay(scratch, Some(notebook), &steps) | |
| 295 | } | |
| 296 | ||
| 297 | /// A template's content is found by search once it is on the page. | |
| 298 | #[test] | |
| 299 | fn search_finds_what_a_template_put_on_the_page() { | |
| 300 | let scratch = Scratch::new("template-search"); | |
| 301 | let notebook = | |
| 302 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 303 | let mut steps: Vec<String> = ["modifiers command", "key n", "modifiers", "wait 1200"] | |
| 304 | .into_iter() | |
| 305 | // The Meeting tile of the template strip over the new page. | |
| 306 | .chain(["move 615 287", "press", "release", "wait 3000"]) | |
| 307 | .map(String::from) | |
| 308 | .collect(); | |
| 309 | steps.extend(search("Attendees", "found")); | |
| 310 | let [found] = run(&scratch, &notebook, &steps).try_into().unwrap(); | |
| 311 | assert!( | |
| 312 | search_results(&found) | |
| 313 | .iter() | |
| 314 | .any(|line| line.contains("Attendees")), | |
| 315 | "{found}" | |
| 316 | ); | |
| 317 | } | |
| 318 | ||
| 319 | /// A version restored is found by search. | |
| 320 | #[test] | |
| 321 | fn search_finds_a_restored_version() { | |
| 322 | let scratch = Scratch::new("restore-search"); | |
| 323 | let notebook = | |
| 324 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore"); | |
| 325 | let mut steps = version_menu(); | |
| 326 | // Restore Version. | |
| 327 | steps.extend(["key Down", "key Enter", "wait 2000"].map(String::from)); | |
| 328 | steps.extend(search("Second author", "found")); | |
| 329 | let [found] = run(&scratch, &notebook, &steps).try_into().unwrap(); | |
| 330 | assert!(!search_results(&found).is_empty(), "{found}"); | |
| 331 | } | |
| 332 | ||
| 333 | /// A version copied into its own section is listed there at once, and found by search. | |
| 334 | #[test] | |
| 335 | fn a_version_copied_into_its_section_is_listed_and_found() { | |
| 336 | let scratch = Scratch::new("copy-version"); | |
| 337 | let notebook = | |
| 338 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore"); | |
| 339 | let mut steps = version_menu(); | |
| 340 | // Copy Page To…, then the section itself. | |
| 341 | steps.extend(["key Down", "key Down", "key Down", "key Enter", "wait 600"].map(String::from)); | |
| 342 | steps.extend(["key Down", "key Enter", "wait 2000", "accessibility copied"].map(String::from)); | |
| 343 | steps.extend(search("Second author", "found")); | |
| 344 | let [copied, found] = run(&scratch, &notebook, &steps).try_into().unwrap(); | |
| 345 | let pages = page_tabs(&copied); | |
| 346 | let versioned = pages | |
| 347 | .iter() | |
| 348 | .filter(|tab| tab.ends_with("Versioned")) | |
| 349 | .count(); | |
| 350 | assert_eq!(versioned, 3, "{pages:?}"); | |
| 351 | assert!(!search_results(&found).is_empty(), "{found}"); | |
| 352 | } |
crates/ui/src/layout.rs+8-9| ... | ... | @@ -316,9 +316,14 @@ fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ { |
| 316 | 316 | /// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest. |
| 317 | 317 | fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 318 | 318 | let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]); |
| 319 | if along(&nodes[index], axis) { | |
| 319 | span(&nodes[index], axis, sizes) | |
| 320 | } | |
| 321 | ||
| 322 | /// The extent of children `sizes` long on `axis` of `node`, as `flow` measures it. | |
| 323 | fn span(node: &Built, axis: usize, sizes: impl Iterator<Item = f32>) -> f32 { | |
| 324 | if along(node, axis) { | |
| 320 | 325 | let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); |
| 321 | sum + nodes[index].gap * (count.max(1) - 1) as f32 | |
| 326 | sum + node.gap * (count.max(1) - 1) as f32 | |
| 322 | 327 | } else { |
| 323 | 328 | sizes.fold(0.0, f32::max) |
| 324 | 329 | } |
| ... | ... | @@ -335,13 +340,7 @@ pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 { |
| 335 | 340 | !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none() |
| 336 | 341 | }) |
| 337 | 342 | .map(|child| least(nodes, *child)); |
| 338 | let content = if along(node, 0) { | |
| 339 | let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); | |
| 340 | sum + node.gap * (count.max(1) - 1) as f32 | |
| 341 | } else { | |
| 342 | sizes.fold(0.0, f32::max) | |
| 343 | }; | |
| 344 | content + 2.0 * node.pad[0] | |
| 343 | span(node, 0, sizes) + 2.0 * node.pad[0] | |
| 345 | 344 | } |
| 346 | 345 | |
| 347 | 346 | /// The least width `index` takes without clipping its children. |
crates/ui/src/lib.rs+23-39| ... | ... | @@ -324,6 +324,15 @@ struct Motion { |
| 324 | 324 | opacity: f32, |
| 325 | 325 | } |
| 326 | 326 | |
| 327 | impl Motion { | |
| 328 | const NONE: Motion = Motion { | |
| 329 | zoom: 1.0, | |
| 330 | pivot: [0.0; 2], | |
| 331 | tilt: 0.0, | |
| 332 | opacity: 1.0, | |
| 333 | }; | |
| 334 | } | |
| 335 | ||
| 327 | 336 | /// What a box is this frame. Colours are linear RGBA. |
| 328 | 337 | #[derive(Clone, Default)] |
| 329 | 338 | pub struct Spec<'a> { |
| ... | ... | @@ -332,13 +341,13 @@ pub struct Spec<'a> { |
| 332 | 341 | /// The axis its children flow along. |
| 333 | 342 | pub axis: Axis, |
| 334 | 343 | pub text: Option<&'a str>, |
| 335 | /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. | |
| 336 | 344 | /// The label's size in logical pixels; the theme's otherwise. |
| 337 | 345 | pub font_size: Option<f32>, |
| 338 | 346 | /// Shapes the label semibold. |
| 339 | 347 | pub bold: bool, |
| 340 | 348 | /// Shapes the label italic. |
| 341 | 349 | pub italic: bool, |
| 350 | /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. | |
| 342 | 351 | pub font: Option<&'a str>, |
| 343 | 352 | pub overflow: Overflow, |
| 344 | 353 | /// The label's colour; the theme's text colour otherwise. |
| ... | ... | @@ -463,12 +472,7 @@ impl Primitives<'_> { |
| 463 | 472 | pivot, |
| 464 | 473 | tilt, |
| 465 | 474 | opacity, |
| 466 | } = self.motion.unwrap_or(Motion { | |
| 467 | zoom: 1.0, | |
| 468 | pivot: [0.0; 2], | |
| 469 | tilt: 0.0, | |
| 470 | opacity: 1.0, | |
| 471 | }); | |
| 475 | } = self.motion.unwrap_or(Motion::NONE); | |
| 472 | 476 | let device = |point: [f32; 2]| point.map(|value| value * scale); |
| 473 | 477 | let moved = |point: [f32; 2]| { |
| 474 | 478 | device(std::array::from_fn(|axis| { |
| ... | ... | @@ -797,8 +801,11 @@ impl Ui { |
| 797 | 801 | } |
| 798 | 802 | |
| 799 | 803 | /// Asks for a frame `after` this one, as a running clock does. |
| 800 | pub fn wake_after(&mut self, after: std::time::Duration) { | |
| 801 | let due = self.now + after; | |
| 804 | pub fn wake_after(&mut self, after: Duration) { | |
| 805 | self.wake_by(self.now + after); | |
| 806 | } | |
| 807 | ||
| 808 | fn wake_by(&mut self, due: Instant) { | |
| 802 | 809 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); |
| 803 | 810 | } |
| 804 | 811 | |
| ... | ... | @@ -1196,7 +1203,7 @@ impl Ui { |
| 1196 | 1203 | |
| 1197 | 1204 | /// The id a box built next under the current parent would take. |
| 1198 | 1205 | pub fn id(&self, part: impl Hash) -> Id { |
| 1199 | self.nodes[*self.stack.last().unwrap()].id.child(part) | |
| 1206 | self.current().child(part) | |
| 1200 | 1207 | } |
| 1201 | 1208 | |
| 1202 | 1209 | /// Adds a box and makes it the parent of the boxes built until `close`. |
| ... | ... | @@ -1247,7 +1254,6 @@ impl Ui { |
| 1247 | 1254 | self.signal(id) |
| 1248 | 1255 | } |
| 1249 | 1256 | |
| 1250 | /// Eases towards `target` over the next frames, starting there the first time `id` asks. | |
| 1251 | 1257 | /// Holds animated value `id` at `value` without easing, as a dragged box follows the |
| 1252 | 1258 | /// pointer; it eases from there once animated again. |
| 1253 | 1259 | pub fn hold(&mut self, id: Id, value: f32) -> f32 { |
| ... | ... | @@ -1257,6 +1263,7 @@ impl Ui { |
| 1257 | 1263 | value |
| 1258 | 1264 | } |
| 1259 | 1265 | |
| 1266 | /// Eases towards `target` over the next frames, starting there the first time `id` asks. | |
| 1260 | 1267 | pub fn animate(&mut self, id: Id, target: f32) -> f32 { |
| 1261 | 1268 | let state = self.states.entry(id).or_default(); |
| 1262 | 1269 | state.touched = self.frame; |
| ... | ... | @@ -1293,7 +1300,7 @@ impl Ui { |
| 1293 | 1300 | /// The size of `text` as a label, in logical pixels. |
| 1294 | 1301 | pub fn measure(&mut self, text: &str) -> [f32; 2] { |
| 1295 | 1302 | self.texts |
| 1296 | .label(text, self.theme.font_size, false, None, self.frame) | |
| 1303 | .label(text, self.theme.font_size, self.frame) | |
| 1297 | 1304 | .size |
| 1298 | 1305 | } |
| 1299 | 1306 | |
| ... | ... | @@ -1316,8 +1323,7 @@ impl Ui { |
| 1316 | 1323 | self.caret = Some((id, start, self.frame)); |
| 1317 | 1324 | let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); |
| 1318 | 1325 | if let Some(hold) = hold { |
| 1319 | let due = self.now + hold; | |
| 1320 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); | |
| 1326 | self.wake_after(hold); | |
| 1321 | 1327 | } |
| 1322 | 1328 | opacity |
| 1323 | 1329 | } |
| ... | ... | @@ -1468,10 +1474,8 @@ impl Ui { |
| 1468 | 1474 | { |
| 1469 | 1475 | let opacity = self.progress(due, TIP_FADE); |
| 1470 | 1476 | self.nodes[index].motion = Some(Motion { |
| 1471 | zoom: 1.0, | |
| 1472 | pivot: [0.0; 2], | |
| 1473 | tilt: 0.0, | |
| 1474 | 1477 | opacity, |
| 1478 | ..Motion::NONE | |
| 1475 | 1479 | }); |
| 1476 | 1480 | self.paint(index, None, None); |
| 1477 | 1481 | } |
| ... | ... | @@ -1961,28 +1965,8 @@ impl Ui { |
| 1961 | 1965 | layers |
| 1962 | 1966 | } |
| 1963 | 1967 | |
| 1964 | pub(crate) fn texts(&mut self) -> (&mut Texts, u64) { | |
| 1965 | (&mut self.texts, self.frame) | |
| 1966 | } | |
| 1967 | ||
| 1968 | pub(crate) fn field( | |
| 1969 | &mut self, | |
| 1970 | id: Id, | |
| 1971 | ) -> ( | |
| 1972 | &mut Selection, | |
| 1973 | &mut (Selection, SelectionUnit), | |
| 1974 | &mut Option<[usize; 2]>, | |
| 1975 | ) { | |
| 1976 | let state = self.states.entry(id).or_default(); | |
| 1977 | (&mut state.selection, &mut state.press, &mut state.select) | |
| 1978 | } | |
| 1979 | ||
| 1980 | pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { | |
| 1981 | &mut self.states.entry(id).or_default().grab | |
| 1982 | } | |
| 1983 | ||
| 1984 | pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> { | |
| 1985 | &mut self.states.entry(id).or_default().held | |
| 1968 | pub(crate) fn state(&mut self, id: Id) -> &mut State { | |
| 1969 | self.states.entry(id).or_default() | |
| 1986 | 1970 | } |
| 1987 | 1971 | } |
| 1988 | 1972 |
crates/ui/src/popup.rs+55-96| ... | ... | @@ -86,6 +86,13 @@ impl Item<'_> { |
| 86 | 86 | } |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | /// Whether `items`' rows leave room for an icon or a check. | |
| 90 | fn icons(items: &[Item]) -> bool { | |
| 91 | items | |
| 92 | .iter() | |
| 93 | .any(|item| item.icon.is_some() || item.checked == Some(true)) | |
| 94 | } | |
| 95 | ||
| 89 | 96 | /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a |
| 90 | 97 | /// filter field showing `filter` while empty when given. Returns the index of the item |
| 91 | 98 | /// chosen. |
| ... | ... | @@ -100,11 +107,7 @@ pub fn menu( |
| 100 | 107 | return None; |
| 101 | 108 | } |
| 102 | 109 | let style = ui.theme.menu(); |
| 103 | let mut measure = |text| { | |
| 104 | ui.texts | |
| 105 | .label(text, style.font_size, false, None, ui.frame) | |
| 106 | .size[0] | |
| 107 | }; | |
| 110 | let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0]; | |
| 108 | 111 | let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { |
| 109 | 112 | [ |
| 110 | 113 | text.max( |
| ... | ... | @@ -118,15 +121,12 @@ pub fn menu( |
| 118 | 121 | shortcut.max(measure(item.trailing())), |
| 119 | 122 | ] |
| 120 | 123 | }); |
| 121 | let icons = items | |
| 122 | .iter() | |
| 123 | .any(|item| item.icon.is_some() || item.checked == Some(true)); | |
| 124 | 124 | let width = |
| 125 | 125 | text + if shortcut > 0.0 { |
| 126 | 126 | 3.0 * ICON_GAP + shortcut |
| 127 | 127 | } else { |
| 128 | 128 | 0.0 |
| 129 | } + if icons { ICON + ICON_GAP } else { 0.0 } | |
| 129 | } + if icons(items) { ICON + ICON_GAP } else { 0.0 } | |
| 130 | 130 | + if items.len() as f32 > ROWS { |
| 131 | 131 | GUTTER |
| 132 | 132 | } else { |
| ... | ... | @@ -189,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> |
| 189 | 189 | return; |
| 190 | 190 | }; |
| 191 | 191 | if now < due { |
| 192 | ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); | |
| 192 | ui.wake_by(due); | |
| 193 | 193 | return; |
| 194 | 194 | } |
| 195 | 195 | popup.submenu = Some((key, None)); |
| ... | ... | @@ -283,7 +283,7 @@ fn tooltip_below( |
| 283 | 283 | return; |
| 284 | 284 | }; |
| 285 | 285 | if now < due { |
| 286 | ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); | |
| 286 | ui.wake_by(due); | |
| 287 | 287 | return; |
| 288 | 288 | } |
| 289 | 289 | let title = if keys.is_empty() { |
| ... | ... | @@ -723,34 +723,8 @@ pub fn colors( |
| 723 | 723 | // The button is cell 0 and the swatches follow it. |
| 724 | 724 | let cell = |index: usize| id.child(("cell", index)); |
| 725 | 725 | let count = swatches.len() + 1; |
| 726 | let keys = navigation( | |
| 727 | ui, | |
| 728 | &[id], | |
| 729 | &[ | |
| 730 | NamedKey::ArrowLeft, | |
| 731 | NamedKey::ArrowRight, | |
| 732 | NamedKey::ArrowUp, | |
| 733 | NamedKey::ArrowDown, | |
| 734 | NamedKey::Enter, | |
| 735 | ], | |
| 736 | ); | |
| 737 | let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); | |
| 738 | let mut chosen = None; | |
| 739 | for index in 0..count { | |
| 740 | let signal = ui.signal(cell(index)); | |
| 741 | if signal.hovered && ui.moved { | |
| 742 | highlight = Some(index); | |
| 743 | } | |
| 744 | if signal.clicked { | |
| 745 | chosen = Some(index); | |
| 746 | } | |
| 747 | } | |
| 748 | for key in keys { | |
| 749 | highlight = Some(match (key, highlight) { | |
| 750 | (NamedKey::Enter, _) => { | |
| 751 | chosen = chosen.or(highlight); | |
| 752 | continue; | |
| 753 | } | |
| 726 | let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| { | |
| 727 | match (key, highlight) { | |
| 754 | 728 | (_, None) => 0, |
| 755 | 729 | (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), |
| 756 | 730 | (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), |
| ... | ... | @@ -758,8 +732,8 @@ pub fn colors( |
| 758 | 732 | (_, Some(0)) => 1, |
| 759 | 733 | (_, Some(at)) if at + columns < count => at + columns, |
| 760 | 734 | (_, Some(at)) => at, |
| 761 | }); | |
| 762 | } | |
| 735 | } | |
| 736 | }); | |
| 763 | 737 | if let Some(index) = chosen { |
| 764 | 738 | ui.close_popup(id); |
| 765 | 739 | return Some((index > 0).then(|| swatches[index - 1].0)); |
| ... | ... | @@ -834,7 +808,6 @@ pub fn colors( |
| 834 | 808 | } |
| 835 | 809 | highlighted(ui, id, highlight.map(cell)); |
| 836 | 810 | ui.close(); |
| 837 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | |
| 838 | 811 | None |
| 839 | 812 | } |
| 840 | 813 | |
| ... | ... | @@ -845,14 +818,15 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) { |
| 845 | 818 | } |
| 846 | 819 | } |
| 847 | 820 | |
| 848 | /// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, | |
| 849 | /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the | |
| 850 | /// columns and rows chosen. | |
| 851 | pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> { | |
| 852 | if !ui.popup_open(id) { | |
| 853 | return None; | |
| 854 | } | |
| 855 | let cell = |index: usize| id.child(("cell", index)); | |
| 821 | /// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight, | |
| 822 | /// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose. | |
| 823 | fn pick_cell( | |
| 824 | ui: &mut Ui, | |
| 825 | id: Id, | |
| 826 | count: usize, | |
| 827 | cell: impl Fn(usize) -> Id, | |
| 828 | step: impl Fn(NamedKey, Option<usize>) -> usize, | |
| 829 | ) -> (Option<usize>, Option<usize>) { | |
| 856 | 830 | let keys = navigation( |
| 857 | 831 | ui, |
| 858 | 832 | &[id], |
| ... | ... | @@ -864,10 +838,9 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 864 | 838 | NamedKey::Enter, |
| 865 | 839 | ], |
| 866 | 840 | ); |
| 867 | let [columns, rows] = size; | |
| 868 | 841 | let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); |
| 869 | 842 | let mut chosen = None; |
| 870 | for index in 0..columns * rows { | |
| 843 | for index in 0..count { | |
| 871 | 844 | let signal = ui.signal(cell(index)); |
| 872 | 845 | if signal.hovered && ui.moved { |
| 873 | 846 | highlight = Some(index); |
| ... | ... | @@ -877,20 +850,36 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 877 | 850 | } |
| 878 | 851 | } |
| 879 | 852 | for key in keys { |
| 880 | let at = highlight.unwrap_or(0); | |
| 853 | match key { | |
| 854 | NamedKey::Enter => chosen = chosen.or(highlight), | |
| 855 | _ => highlight = Some(step(key, highlight)), | |
| 856 | } | |
| 857 | } | |
| 858 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | |
| 859 | (highlight, chosen) | |
| 860 | } | |
| 861 | ||
| 862 | /// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, | |
| 863 | /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the | |
| 864 | /// columns and rows chosen. | |
| 865 | pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> { | |
| 866 | if !ui.popup_open(id) { | |
| 867 | return None; | |
| 868 | } | |
| 869 | let cell = |index: usize| id.child(("cell", index)); | |
| 870 | let [columns, rows] = size; | |
| 871 | let (highlight, chosen) = pick_cell(ui, id, columns * rows, cell, |key, highlight| { | |
| 872 | let Some(at) = highlight else { | |
| 873 | return 0; | |
| 874 | }; | |
| 881 | 875 | let [column, row] = [at % columns, at / columns]; |
| 882 | highlight = Some(match key { | |
| 883 | NamedKey::Enter => { | |
| 884 | chosen = chosen.or(highlight); | |
| 885 | continue; | |
| 886 | } | |
| 887 | _ if highlight.is_none() => 0, | |
| 876 | match key { | |
| 888 | 877 | NamedKey::ArrowLeft => at - usize::from(column > 0), |
| 889 | 878 | NamedKey::ArrowRight => at + usize::from(column + 1 < columns), |
| 890 | 879 | NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 }, |
| 891 | 880 | _ => at + if row + 1 < rows { columns } else { 0 }, |
| 892 | }); | |
| 893 | } | |
| 881 | } | |
| 882 | }); | |
| 894 | 883 | let extent = |index: usize| [index % columns + 1, index / columns + 1]; |
| 895 | 884 | if let Some(index) = chosen { |
| 896 | 885 | ui.close_popup(id); |
| ... | ... | @@ -971,7 +960,6 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 971 | 960 | ui.close(); |
| 972 | 961 | highlighted(ui, id, highlight.map(cell)); |
| 973 | 962 | ui.close(); |
| 974 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | |
| 975 | 963 | None |
| 976 | 964 | } |
| 977 | 965 | |
| ... | ... | @@ -1011,41 +999,15 @@ pub fn gallery( |
| 1011 | 999 | }) |
| 1012 | 1000 | .map_or(1, |group| group.columns) |
| 1013 | 1001 | }; |
| 1014 | let keys = navigation( | |
| 1015 | ui, | |
| 1016 | &[id], | |
| 1017 | &[ | |
| 1018 | NamedKey::ArrowLeft, | |
| 1019 | NamedKey::ArrowRight, | |
| 1020 | NamedKey::ArrowUp, | |
| 1021 | NamedKey::ArrowDown, | |
| 1022 | NamedKey::Enter, | |
| 1023 | ], | |
| 1024 | ); | |
| 1025 | let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); | |
| 1026 | let mut chosen = None; | |
| 1027 | for index in 0..count { | |
| 1028 | let signal = ui.signal(cell_id(index)); | |
| 1029 | if signal.hovered && ui.moved { | |
| 1030 | highlight = Some(index); | |
| 1031 | } | |
| 1032 | if signal.clicked { | |
| 1033 | chosen = Some(index); | |
| 1034 | } | |
| 1035 | } | |
| 1036 | for key in keys { | |
| 1002 | let (highlight, chosen) = pick_cell(ui, id, count, cell_id, |key, highlight| { | |
| 1037 | 1003 | let at = highlight.or(current.first().copied()).unwrap_or(0); |
| 1038 | highlight = Some(match key { | |
| 1039 | NamedKey::Enter => { | |
| 1040 | chosen = chosen.or(highlight); | |
| 1041 | continue; | |
| 1042 | } | |
| 1004 | match key { | |
| 1043 | 1005 | NamedKey::ArrowLeft => at.saturating_sub(1), |
| 1044 | 1006 | NamedKey::ArrowRight => (at + 1).min(count - 1), |
| 1045 | 1007 | NamedKey::ArrowUp => at.saturating_sub(columns(at)), |
| 1046 | 1008 | _ => (at + columns(at)).min(count - 1), |
| 1047 | }); | |
| 1048 | } | |
| 1009 | } | |
| 1010 | }); | |
| 1049 | 1011 | if let Some(index) = chosen { |
| 1050 | 1012 | ui.close_popup(id); |
| 1051 | 1013 | return Some(index); |
| ... | ... | @@ -1113,7 +1075,6 @@ pub fn gallery( |
| 1113 | 1075 | } |
| 1114 | 1076 | highlighted(ui, id, highlight.map(cell_id)); |
| 1115 | 1077 | ui.close(); |
| 1116 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | |
| 1117 | 1078 | None |
| 1118 | 1079 | } |
| 1119 | 1080 | |
| ... | ... | @@ -1488,9 +1449,7 @@ impl<'a> Matches<'a> { |
| 1488 | 1449 | rules, |
| 1489 | 1450 | ruled, |
| 1490 | 1451 | rule, |
| 1491 | icons: items | |
| 1492 | .iter() | |
| 1493 | .any(|item| item.icon.is_some() || item.checked == Some(true)), | |
| 1452 | icons: icons(items), | |
| 1494 | 1453 | } |
| 1495 | 1454 | } |
| 1496 | 1455 | } |
crates/ui/src/shell.rs+15-9| ... | ... | @@ -34,12 +34,12 @@ const EDGE: f32 = 20.0; |
| 34 | 34 | /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. |
| 35 | 35 | /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the |
| 36 | 36 | /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it |
| 37 | /// opens. | |
| 37 | /// opens. A tab given `true` after its colour reads bold, as an unread section's does. | |
| 38 | 38 | #[allow(clippy::too_many_arguments)] |
| 39 | 39 | pub fn section_tabs( |
| 40 | 40 | ui: &mut Ui, |
| 41 | 41 | row: Id, |
| 42 | tabs: &[(&str, [f32; 4])], | |
| 42 | tabs: &[(&str, [f32; 4], bool)], | |
| 43 | 43 | active: usize, |
| 44 | 44 | lit: Option<usize>, |
| 45 | 45 | dragged: Option<Dragged>, |
| ... | ... | @@ -52,8 +52,11 @@ pub fn section_tabs( |
| 52 | 52 | let mut left = crate::SHADOW[0]; |
| 53 | 53 | let placed: Vec<_> = tabs |
| 54 | 54 | .iter() |
| 55 | .map(|(name, _)| { | |
| 56 | let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height); | |
| 55 | .map(|&(name, _, bold)| { | |
| 56 | let label = ui | |
| 57 | .texts | |
| 58 | .styled(name, ui.theme.font_size, bold, false, None, ui.frame); | |
| 59 | let width = TAB_PAD + label.size[0] + 4.0 + lean(height); | |
| 57 | 60 | left += width; |
| 58 | 61 | (left - width, width) |
| 59 | 62 | }) |
| ... | ... | @@ -129,7 +132,7 @@ pub fn section_tabs( |
| 129 | 132 | .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) |
| 130 | 133 | .chain(lifted); |
| 131 | 134 | for index in order { |
| 132 | let (name, color) = tabs[index]; | |
| 135 | let (name, color, bold) = tabs[index]; | |
| 133 | 136 | let (x, width) = placed[index]; |
| 134 | 137 | // Keyed by name, so a tab eases from where it stood when the tabs are reordered. |
| 135 | 138 | let key = ui.id(("x", name)); |
| ... | ... | @@ -164,6 +167,7 @@ pub fn section_tabs( |
| 164 | 167 | size: [px(width), px(tall)], |
| 165 | 168 | position: [x - offset, height - tall], |
| 166 | 169 | text: Some(name), |
| 170 | bold, | |
| 167 | 171 | color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), |
| 168 | 172 | fill: Some(if lit == Some(index) { |
| 169 | 173 | mix(colors.frame[0], [1.0; 4], 0.08) |
| ... | ... | @@ -306,6 +310,11 @@ fn opens(ui: &mut Ui, id: Id, menu: Id) { |
| 306 | 310 | } |
| 307 | 311 | } |
| 308 | 312 | |
| 313 | /// `color` as a control that does not apply now shows it. | |
| 314 | fn faded([red, green, blue, alpha]: [f32; 4]) -> [f32; 4] { | |
| 315 | [red, green, blue, alpha * 0.35] | |
| 316 | } | |
| 317 | ||
| 309 | 318 | /// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon` |
| 310 | 319 | /// tinted by `tint` shows faded, and it takes no clicks. |
| 311 | 320 | pub fn unavailable( |
| ... | ... | @@ -315,7 +324,6 @@ pub fn unavailable( |
| 315 | 324 | tint: [f32; 4], |
| 316 | 325 | menu: bool, |
| 317 | 326 | ) { |
| 318 | let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35]; | |
| 319 | 327 | let arrow = faded(ui.theme.text_dim); |
| 320 | 328 | let id = ui.open( |
| 321 | 329 | part, |
| ... | ... | @@ -530,9 +538,7 @@ pub fn combo( |
| 530 | 538 | enabled: bool, |
| 531 | 539 | ) { |
| 532 | 540 | let theme = ui.theme.clone(); |
| 533 | let faded = |[red, green, blue, alpha]: [f32; 4]| { | |
| 534 | [red, green, blue, if enabled { alpha } else { alpha * 0.35 }] | |
| 535 | }; | |
| 541 | let faded = |color| if enabled { color } else { faded(color) }; | |
| 536 | 542 | let id = ui.open( |
| 537 | 543 | part, |
| 538 | 544 | Spec { |
crates/ui/src/tests.rs+3-3| ... | ... | @@ -605,8 +605,8 @@ fn focused_field(text: &str) -> (Ui, String) { |
| 605 | 605 | /// Moves the pointer over the caret before byte `index` of the field's `text`. |
| 606 | 606 | fn point_to(ui: &mut Ui, text: &str, index: usize) { |
| 607 | 607 | let size = ui.theme.font_size; |
| 608 | let (texts, frame) = ui.texts(); | |
| 609 | let layout = &texts.label(text, size, false, None, frame).layout; | |
| 608 | let (texts, frame) = (&mut ui.texts, ui.frame); | |
| 609 | let layout = &texts.label(text, size, frame).layout; | |
| 610 | 610 | let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) |
| 611 | 611 | .geometry(layout, 1.0) |
| 612 | 612 | .x0 as f32; |
| ... | ... | @@ -2817,7 +2817,7 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id { |
| 2817 | 2817 | ]; |
| 2818 | 2818 | let tabs: Vec<_> = names |
| 2819 | 2819 | .iter() |
| 2820 | .map(|name| (*name, [0.5, 0.6, 0.9, 1.0])) | |
| 2820 | .map(|name| (*name, [0.5, 0.6, 0.9, 1.0], false)) | |
| 2821 | 2821 | .collect(); |
| 2822 | 2822 | let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); |
| 2823 | 2823 | let row = Id::ROOT.child("tabs"); |
crates/ui/src/text.rs+4-11| ... | ... | @@ -24,19 +24,12 @@ pub(crate) struct Texts { |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | impl Texts { |
| 27 | /// `size` is in logical pixels; `font` names a family to prefer over the interface's. | |
| 28 | pub fn label( | |
| 29 | &mut self, | |
| 30 | text: &str, | |
| 31 | size: f32, | |
| 32 | bold: bool, | |
| 33 | font: Option<&str>, | |
| 34 | frame: u64, | |
| 35 | ) -> Rc<Label> { | |
| 36 | self.styled(text, size, bold, false, font, frame) | |
| 27 | /// `text` in the interface's regular face, `size` logical pixels. | |
| 28 | pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> { | |
| 29 | self.styled(text, size, false, false, None, frame) | |
| 37 | 30 | } |
| 38 | 31 | |
| 39 | /// `label`, slanted where `italic`. | |
| 32 | /// `font` names a family to prefer over the interface's. | |
| 40 | 33 | pub fn styled( |
| 41 | 34 | &mut self, |
| 42 | 35 | text: &str, |
crates/ui/src/theme.rs+7-1| ... | ... | @@ -13,8 +13,12 @@ pub struct Theme { |
| 13 | 13 | pub strip: [f32; 4], |
| 14 | 14 | /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop. |
| 15 | 15 | pub sidebar: [f32; 4], |
| 16 | /// Toolbar buttons at rest: clear, or a face where the backdrop would hide their icons. | |
| 16 | /// Toolbar buttons and their groups at rest: clear, or a face where the backdrop would | |
| 17 | /// hide their icons. | |
| 17 | 18 | pub tool: [f32; 4], |
| 19 | /// Gathers the toolbar's groups on one `tool` face the length of the row, ruled between | |
| 20 | /// groups, rather than a face per group. | |
| 21 | pub tool_panel: bool, | |
| 18 | 22 | pub accent: [f32; 4], |
| 19 | 23 | /// The text caret, and selected text's fill with and without keyboard focus; the |
| 20 | 24 | /// platform's own where it has them. |
| ... | ... | @@ -145,6 +149,7 @@ impl Theme { |
| 145 | 149 | strip: srgb(0x27, 0x2a, 0x2b), |
| 146 | 150 | sidebar: srgb(0x27, 0x2a, 0x2b), |
| 147 | 151 | tool: [0.0; 4], |
| 152 | tool_panel: false, | |
| 148 | 153 | accent: srgb(0x00, 0x79, 0xa6), |
| 149 | 154 | caret, |
| 150 | 155 | selection, |
| ... | ... | @@ -176,6 +181,7 @@ impl Theme { |
| 176 | 181 | strip: srgb(0xeb, 0xed, 0xf0), |
| 177 | 182 | sidebar: srgb(0xeb, 0xed, 0xf0), |
| 178 | 183 | tool: [0.0; 4], |
| 184 | tool_panel: false, | |
| 179 | 185 | accent: srgb(0x00, 0x79, 0xa6), |
| 180 | 186 | caret, |
| 181 | 187 | selection, |
crates/ui/src/widgets.rs+18-22| ... | ... | @@ -162,11 +162,11 @@ pub fn scrollbar( |
| 162 | 162 | if signal.pressed |
| 163 | 163 | && let Some(pointer) = pointer |
| 164 | 164 | { |
| 165 | *ui.grab(id) = pointer - start; | |
| 165 | ui.state(id).grab = pointer - start; | |
| 166 | 166 | } |
| 167 | 167 | let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| { |
| 168 | 168 | let travel = (track - size).max(f32::EPSILON); |
| 169 | let fraction = ((pointer - *ui.grab(id) - 4.0) / travel).clamp(0.0, 1.0); | |
| 169 | let fraction = ((pointer - ui.state(id).grab - 4.0) / travel).clamp(0.0, 1.0); | |
| 170 | 170 | range[0] + fraction * span |
| 171 | 171 | }); |
| 172 | 172 | let mut position = [0.0; 2]; |
| ... | ... | @@ -212,7 +212,7 @@ fn system_scrollbar( |
| 212 | 212 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); |
| 213 | 213 | let now = ui.now; |
| 214 | 214 | if !signal.pressed && !signal.dragging { |
| 215 | *ui.held(id) = None; | |
| 215 | ui.state(id).held = None; | |
| 216 | 216 | } |
| 217 | 217 | let request = Scroller { |
| 218 | 218 | axis, |
| ... | ... | @@ -220,7 +220,7 @@ fn system_scrollbar( |
| 220 | 220 | scale: ui.scale, |
| 221 | 221 | value: ((offset - range[0]) / span).clamp(0.0, 1.0), |
| 222 | 222 | proportion: (view / (view + span)).clamp(0.0, 1.0), |
| 223 | held: ui.held(id).map(|(part, _)| part), | |
| 223 | held: ui.state(id).held.map(|(part, _)| part), | |
| 224 | 224 | active: ui.window_focused, |
| 225 | 225 | }; |
| 226 | 226 | let painted = (ui.scrollers.as_mut()?.paint)(&request); |
| ... | ... | @@ -239,15 +239,15 @@ fn system_scrollbar( |
| 239 | 239 | ] |
| 240 | 240 | .into_iter() |
| 241 | 241 | .find(|(_, extent)| within(*extent, at)); |
| 242 | *ui.held(id) = hit.map(|(part, _)| (part, now)); | |
| 243 | *ui.grab(id) = at - painted.knob[0]; | |
| 242 | ui.state(id).held = hit.map(|(part, _)| (part, now)); | |
| 243 | ui.state(id).grab = at - painted.knob[0]; | |
| 244 | 244 | } |
| 245 | match *ui.held(id) { | |
| 245 | match ui.state(id).held { | |
| 246 | 246 | Some((ScrollerPart::Knob, _)) => { |
| 247 | 247 | let travel = |
| 248 | 248 | (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]); |
| 249 | let fraction = ((at - *ui.grab(id) - painted.slot[0]) / travel.max(f32::EPSILON)) | |
| 250 | .clamp(0.0, 1.0); | |
| 249 | let from = at - ui.state(id).grab - painted.slot[0]; | |
| 250 | let fraction = (from / travel.max(f32::EPSILON)).clamp(0.0, 1.0); | |
| 251 | 251 | chosen = Some(range[0] + fraction * span); |
| 252 | 252 | } |
| 253 | 253 | Some((part, due)) if now >= due => { |
| ... | ... | @@ -260,7 +260,7 @@ fn system_scrollbar( |
| 260 | 260 | _ => view - line, |
| 261 | 261 | }; |
| 262 | 262 | let delay = if signal.pressed { REPEAT_DELAY } else { REPEAT }; |
| 263 | *ui.held(id) = Some((part, now + delay)); | |
| 263 | ui.state(id).held = Some((part, now + delay)); | |
| 264 | 264 | chosen = Some((offset + step).clamp(range[0], range[1])); |
| 265 | 265 | } |
| 266 | 266 | _ => {} |
| ... | ... | @@ -360,13 +360,11 @@ fn field( |
| 360 | 360 | .zip(ui.rect(id)) |
| 361 | 361 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) |
| 362 | 362 | .filter(|_| signal.pressed || signal.dragging); |
| 363 | let (mut selection, mut press, select) = { | |
| 364 | let (selection, press, select) = ui.field(id); | |
| 365 | (*selection, *press, select.take()) | |
| 366 | }; | |
| 363 | let state = ui.state(id); | |
| 364 | let (mut selection, mut press, select) = (state.selection, state.press, state.select.take()); | |
| 367 | 365 | let before = [selection.anchor(), selection.focus()]; |
| 368 | let (texts, frame) = ui.texts(); | |
| 369 | let mut label = texts.label(&shown_text, size, false, None, frame); | |
| 366 | let (texts, frame) = (&mut ui.texts, ui.frame); | |
| 367 | let mut label = texts.label(&shown_text, size, frame); | |
| 370 | 368 | if let Some(x) = pointer { |
| 371 | 369 | let layout = &label.layout; |
| 372 | 370 | let unit = if signal.pressed { signal.unit } else { press.1 }; |
| ... | ... | @@ -470,7 +468,7 @@ fn field( |
| 470 | 468 | let range = to_text(text, range.start)..to_text(text, range.end); |
| 471 | 469 | text.replace_range(range.clone(), inserted); |
| 472 | 470 | shown_text = masked(text); |
| 473 | label = texts.label(&shown_text, size, false, None, frame); | |
| 471 | label = texts.label(&shown_text, size, frame); | |
| 474 | 472 | selection = Selection::from_byte_index( |
| 475 | 473 | &label.layout, |
| 476 | 474 | to_shown(text, range.start + inserted.len()), |
| ... | ... | @@ -478,11 +476,9 @@ fn field( |
| 478 | 476 | ); |
| 479 | 477 | } |
| 480 | 478 | let moved = signal.pressed || before != [selection.anchor(), selection.focus()]; |
| 481 | { | |
| 482 | let (stored, stored_press, _) = ui.field(id); | |
| 483 | *stored = selection; | |
| 484 | *stored_press = press; | |
| 485 | } | |
| 479 | let state = ui.state(id); | |
| 480 | state.selection = selection; | |
| 481 | state.press = press; | |
| 486 | 482 | let theme = ui.theme.clone(); |
| 487 | 483 | let shown = if text.is_empty() { |
| 488 | 484 | placeholder |
fuzz/Cargo.lock+240| ... | ... | @@ -2,6 +2,22 @@ |
| 2 | 2 | # It is not intended for manual editing. |
| 3 | 3 | version = 4 |
| 4 | 4 | |
| 5 | [[package]] | |
| 6 | name = "adler2" | |
| 7 | version = "2.0.1" | |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | ||
| 11 | [[package]] | |
| 12 | name = "aead" | |
| 13 | version = "0.6.1" | |
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" | |
| 16 | dependencies = [ | |
| 17 | "crypto-common", | |
| 18 | "inout", | |
| 19 | ] | |
| 20 | ||
| 5 | 21 | [[package]] |
| 6 | 22 | name = "aes" |
| 7 | 23 | version = "0.9.3" |
| ... | ... | @@ -14,6 +30,20 @@ dependencies = [ |
| 14 | 30 | "zeroize", |
| 15 | 31 | ] |
| 16 | 32 | |
| 33 | [[package]] | |
| 34 | name = "aes-gcm" | |
| 35 | version = "0.11.1" | |
| 36 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 37 | checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" | |
| 38 | dependencies = [ | |
| 39 | "aead", | |
| 40 | "aes", | |
| 41 | "cipher", | |
| 42 | "ctr", | |
| 43 | "ctutils", | |
| 44 | "ghash", | |
| 45 | ] | |
| 46 | ||
| 17 | 47 | [[package]] |
| 18 | 48 | name = "arbitrary" |
| 19 | 49 | version = "1.4.2" |
| ... | ... | @@ -77,6 +107,24 @@ dependencies = [ |
| 77 | 107 | "syn 3.0.5", |
| 78 | 108 | ] |
| 79 | 109 | |
| 110 | [[package]] | |
| 111 | name = "byteorder" | |
| 112 | version = "1.5.0" | |
| 113 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 115 | ||
| 116 | [[package]] | |
| 117 | name = "cab" | |
| 118 | version = "0.6.0" | |
| 119 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 120 | checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2" | |
| 121 | dependencies = [ | |
| 122 | "byteorder", | |
| 123 | "flate2", | |
| 124 | "lzxd", | |
| 125 | "time", | |
| 126 | ] | |
| 127 | ||
| 80 | 128 | [[package]] |
| 81 | 129 | name = "canvas" |
| 82 | 130 | version = "0.1.0" |
| ... | ... | @@ -135,6 +183,12 @@ dependencies = [ |
| 135 | 183 | "zeroize", |
| 136 | 184 | ] |
| 137 | 185 | |
| 186 | [[package]] | |
| 187 | name = "cmov" | |
| 188 | version = "0.5.4" | |
| 189 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 190 | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | |
| 191 | ||
| 138 | 192 | [[package]] |
| 139 | 193 | name = "const-oid" |
| 140 | 194 | version = "0.10.2" |
| ... | ... | @@ -156,15 +210,50 @@ dependencies = [ |
| 156 | 210 | "libc", |
| 157 | 211 | ] |
| 158 | 212 | |
| 213 | [[package]] | |
| 214 | name = "crc32fast" | |
| 215 | version = "1.5.2" | |
| 216 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 217 | checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" | |
| 218 | dependencies = [ | |
| 219 | "cfg-if", | |
| 220 | ] | |
| 221 | ||
| 159 | 222 | [[package]] |
| 160 | 223 | name = "crypto-common" |
| 161 | 224 | version = "0.2.2" |
| 162 | 225 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 163 | 226 | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" |
| 164 | 227 | dependencies = [ |
| 228 | "getrandom", | |
| 165 | 229 | "hybrid-array", |
| 230 | "rand_core", | |
| 166 | 231 | ] |
| 167 | 232 | |
| 233 | [[package]] | |
| 234 | name = "ctr" | |
| 235 | version = "0.10.1" | |
| 236 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 237 | checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" | |
| 238 | dependencies = [ | |
| 239 | "cipher", | |
| 240 | ] | |
| 241 | ||
| 242 | [[package]] | |
| 243 | name = "ctutils" | |
| 244 | version = "0.4.2" | |
| 245 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 246 | checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" | |
| 247 | dependencies = [ | |
| 248 | "cmov", | |
| 249 | ] | |
| 250 | ||
| 251 | [[package]] | |
| 252 | name = "deranged" | |
| 253 | version = "0.5.8" | |
| 254 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 255 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 256 | ||
| 168 | 257 | [[package]] |
| 169 | 258 | name = "digest" |
| 170 | 259 | version = "0.11.3" |
| ... | ... | @@ -174,6 +263,7 @@ dependencies = [ |
| 174 | 263 | "block-buffer", |
| 175 | 264 | "const-oid", |
| 176 | 265 | "crypto-common", |
| 266 | "ctutils", | |
| 177 | 267 | "zeroize", |
| 178 | 268 | ] |
| 179 | 269 | |
| ... | ... | @@ -232,12 +322,32 @@ version = "2.5.0" |
| 232 | 322 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 233 | 323 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
| 234 | 324 | |
| 325 | [[package]] | |
| 326 | name = "file-guard" | |
| 327 | version = "0.2.0" | |
| 328 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 329 | checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" | |
| 330 | dependencies = [ | |
| 331 | "libc", | |
| 332 | "winapi", | |
| 333 | ] | |
| 334 | ||
| 235 | 335 | [[package]] |
| 236 | 336 | name = "find-msvc-tools" |
| 237 | 337 | version = "0.1.12" |
| 238 | 338 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 239 | 339 | checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" |
| 240 | 340 | |
| 341 | [[package]] | |
| 342 | name = "flate2" | |
| 343 | version = "1.1.10" | |
| 344 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 345 | checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | |
| 346 | dependencies = [ | |
| 347 | "crc32fast", | |
| 348 | "miniz_oxide", | |
| 349 | ] | |
| 350 | ||
| 241 | 351 | [[package]] |
| 242 | 352 | name = "foldhash" |
| 243 | 353 | version = "0.2.0" |
| ... | ... | @@ -283,6 +393,16 @@ dependencies = [ |
| 283 | 393 | "cfg-if", |
| 284 | 394 | "libc", |
| 285 | 395 | "r-efi", |
| 396 | "rand_core", | |
| 397 | ] | |
| 398 | ||
| 399 | [[package]] | |
| 400 | name = "ghash" | |
| 401 | version = "0.6.0" | |
| 402 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 403 | checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" | |
| 404 | dependencies = [ | |
| 405 | "polyval", | |
| 286 | 406 | ] |
| 287 | 407 | |
| 288 | 408 | [[package]] |
| ... | ... | @@ -324,6 +444,15 @@ dependencies = [ |
| 324 | 444 | "hashbrown 0.17.1", |
| 325 | 445 | ] |
| 326 | 446 | |
| 447 | [[package]] | |
| 448 | name = "hmac" | |
| 449 | version = "0.13.0" | |
| 450 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 451 | checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" | |
| 452 | dependencies = [ | |
| 453 | "digest", | |
| 454 | ] | |
| 455 | ||
| 327 | 456 | [[package]] |
| 328 | 457 | name = "hybrid-array" |
| 329 | 458 | version = "0.4.14" |
| ... | ... | @@ -556,6 +685,12 @@ version = "0.8.3" |
| 556 | 685 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 557 | 686 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" |
| 558 | 687 | |
| 688 | [[package]] | |
| 689 | name = "lzxd" | |
| 690 | version = "0.2.7" | |
| 691 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 692 | checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168" | |
| 693 | ||
| 559 | 694 | [[package]] |
| 560 | 695 | name = "md5" |
| 561 | 696 | version = "0.8.1" |
| ... | ... | @@ -577,6 +712,16 @@ dependencies = [ |
| 577 | 712 | "libc", |
| 578 | 713 | ] |
| 579 | 714 | |
| 715 | [[package]] | |
| 716 | name = "miniz_oxide" | |
| 717 | version = "0.9.1" | |
| 718 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 719 | checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | |
| 720 | dependencies = [ | |
| 721 | "adler2", | |
| 722 | "simd-adler32", | |
| 723 | ] | |
| 724 | ||
| 580 | 725 | [[package]] |
| 581 | 726 | name = "nix" |
| 582 | 727 | version = "0.31.3" |
| ... | ... | @@ -593,6 +738,12 @@ dependencies = [ |
| 593 | 738 | name = "notebook" |
| 594 | 739 | version = "0.1.0" |
| 595 | 740 | dependencies = [ |
| 741 | "aes-gcm", | |
| 742 | "base64", | |
| 743 | "cab", | |
| 744 | "getrandom", | |
| 745 | "hmac", | |
| 746 | "nix", | |
| 596 | 747 | "onestore", |
| 597 | 748 | "rusqlite", |
| 598 | 749 | "serde", |
| ... | ... | @@ -600,8 +751,16 @@ dependencies = [ |
| 600 | 751 | "sha2", |
| 601 | 752 | "tempfile", |
| 602 | 753 | "thiserror", |
| 754 | "windows-sys", | |
| 755 | "zeroize", | |
| 603 | 756 | ] |
| 604 | 757 | |
| 758 | [[package]] | |
| 759 | name = "num-conv" | |
| 760 | version = "0.2.2" | |
| 761 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 762 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 763 | ||
| 605 | 764 | [[package]] |
| 606 | 765 | name = "objc2" |
| 607 | 766 | version = "0.6.4" |
| ... | ... | @@ -660,6 +819,7 @@ dependencies = [ |
| 660 | 819 | "base64", |
| 661 | 820 | "bumpalo", |
| 662 | 821 | "cbc", |
| 822 | "file-guard", | |
| 663 | 823 | "getrandom", |
| 664 | 824 | "md5", |
| 665 | 825 | "nix", |
| ... | ... | @@ -735,6 +895,17 @@ version = "0.3.34" |
| 735 | 895 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 736 | 896 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" |
| 737 | 897 | |
| 898 | [[package]] | |
| 899 | name = "polyval" | |
| 900 | version = "0.7.3" | |
| 901 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 902 | checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" | |
| 903 | dependencies = [ | |
| 904 | "cpubits", | |
| 905 | "cpufeatures", | |
| 906 | "universal-hash", | |
| 907 | ] | |
| 908 | ||
| 738 | 909 | [[package]] |
| 739 | 910 | name = "potential_utf" |
| 740 | 911 | version = "0.1.6" |
| ... | ... | @@ -746,6 +917,12 @@ dependencies = [ |
| 746 | 917 | "zerovec", |
| 747 | 918 | ] |
| 748 | 919 | |
| 920 | [[package]] | |
| 921 | name = "powerfmt" | |
| 922 | version = "0.2.0" | |
| 923 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 924 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 925 | ||
| 749 | 926 | [[package]] |
| 750 | 927 | name = "proc-macro2" |
| 751 | 928 | version = "1.0.107" |
| ... | ... | @@ -770,6 +947,12 @@ version = "6.0.0" |
| 770 | 947 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 771 | 948 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| 772 | 949 | |
| 950 | [[package]] | |
| 951 | name = "rand_core" | |
| 952 | version = "0.10.1" | |
| 953 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 954 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 955 | ||
| 773 | 956 | [[package]] |
| 774 | 957 | name = "read-fonts" |
| 775 | 958 | version = "0.41.0" |
| ... | ... | @@ -905,6 +1088,12 @@ version = "2.0.1" |
| 905 | 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 906 | 1089 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 907 | 1090 | |
| 1091 | [[package]] | |
| 1092 | name = "simd-adler32" | |
| 1093 | version = "0.3.10" | |
| 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1095 | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 1096 | ||
| 908 | 1097 | [[package]] |
| 909 | 1098 | name = "skrifa" |
| 910 | 1099 | version = "0.44.0" |
| ... | ... | @@ -1011,6 +1200,25 @@ dependencies = [ |
| 1011 | 1200 | "syn 3.0.5", |
| 1012 | 1201 | ] |
| 1013 | 1202 | |
| 1203 | [[package]] | |
| 1204 | name = "time" | |
| 1205 | version = "0.3.55" | |
| 1206 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1207 | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 1208 | dependencies = [ | |
| 1209 | "deranged", | |
| 1210 | "num-conv", | |
| 1211 | "powerfmt", | |
| 1212 | "serde_core", | |
| 1213 | "time-core", | |
| 1214 | ] | |
| 1215 | ||
| 1216 | [[package]] | |
| 1217 | name = "time-core" | |
| 1218 | version = "0.1.9" | |
| 1219 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1220 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 1221 | ||
| 1014 | 1222 | [[package]] |
| 1015 | 1223 | name = "tinystr" |
| 1016 | 1224 | version = "0.8.4" |
| ... | ... | @@ -1034,6 +1242,16 @@ version = "1.0.24" |
| 1034 | 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1035 | 1243 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| 1036 | 1244 | |
| 1245 | [[package]] | |
| 1246 | name = "universal-hash" | |
| 1247 | version = "0.6.1" | |
| 1248 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1249 | checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" | |
| 1250 | dependencies = [ | |
| 1251 | "crypto-common", | |
| 1252 | "ctutils", | |
| 1253 | ] | |
| 1254 | ||
| 1037 | 1255 | [[package]] |
| 1038 | 1256 | name = "utf16_iter" |
| 1039 | 1257 | version = "1.0.5" |
| ... | ... | @@ -1097,6 +1315,28 @@ dependencies = [ |
| 1097 | 1315 | "unicode-ident", |
| 1098 | 1316 | ] |
| 1099 | 1317 | |
| 1318 | [[package]] | |
| 1319 | name = "winapi" | |
| 1320 | version = "0.3.9" | |
| 1321 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1322 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 1323 | dependencies = [ | |
| 1324 | "winapi-i686-pc-windows-gnu", | |
| 1325 | "winapi-x86_64-pc-windows-gnu", | |
| 1326 | ] | |
| 1327 | ||
| 1328 | [[package]] | |
| 1329 | name = "winapi-i686-pc-windows-gnu" | |
| 1330 | version = "0.4.0" | |
| 1331 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1332 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 1333 | ||
| 1334 | [[package]] | |
| 1335 | name = "winapi-x86_64-pc-windows-gnu" | |
| 1336 | version = "0.4.0" | |
| 1337 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1338 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 1339 | ||
| 1100 | 1340 | [[package]] |
| 1101 | 1341 | name = "windows" |
| 1102 | 1342 | version = "0.62.2" |
fuzz/Cargo.toml+1-1| ... | ... | @@ -9,7 +9,7 @@ cargo-fuzz = true |
| 9 | 9 | |
| 10 | 10 | [dependencies] |
| 11 | 11 | libfuzzer-sys = "0.4" |
| 12 | onestore = { path = "../crates/onestore", features = ["protected"] } | |
| 12 | onestore = { path = "../crates/onestore" } | |
| 13 | 13 | notebook = { path = "../crates/notebook" } |
| 14 | 14 | canvas = { path = "../crates/canvas" } |
| 15 | 15 | draw = { path = "../crates/draw", default-features = false } |
fuzz/fuzz_targets/canvas_editor.rs+18-30| ... | ... | @@ -209,37 +209,25 @@ fuzz_target!(|input: &[u8]| { |
| 209 | 209 | .into_iter() |
| 210 | 210 | .enumerate() |
| 211 | 211 | { |
| 212 | objects.push(onestore::page::PageObject::Image(onestore::page::Image { | |
| 213 | id: onestore::ExGuid { | |
| 214 | n: index.try_into().unwrap(), | |
| 215 | ..Default::default() | |
| 216 | }, | |
| 217 | layout: onestore::document::Layout { | |
| 218 | x: Some(x), | |
| 219 | y: Some(position[1] + y), | |
| 220 | max_width: Some(72.0), | |
| 221 | max_height: Some(40.0), | |
| 222 | ..Default::default() | |
| 223 | }, | |
| 224 | bytes: Some(std::sync::Arc::from(&b"deferred decoding"[..])), | |
| 225 | background, | |
| 226 | alt: None, | |
| 227 | size: None, | |
| 228 | })); | |
| 212 | let mut image = | |
| 213 | canvas::editor::picture(b"deferred decoding".to_vec(), [72.0, 40.0]).unwrap(); | |
| 214 | image.id = onestore::ExGuid { | |
| 215 | n: index.try_into().unwrap(), | |
| 216 | ..Default::default() | |
| 217 | }; | |
| 218 | [image.layout.x, image.layout.y] = [Some(x), Some(position[1] + y)]; | |
| 219 | image.background = background; | |
| 220 | objects.push(onestore::page::PageObject::Image(image)); | |
| 229 | 221 | } |
| 230 | CanvasEditor::from_page( | |
| 231 | onestore::page::Page { | |
| 232 | title: String::new(), | |
| 233 | identity: None, | |
| 234 | created: date.map(|date| date.timestamp()), | |
| 235 | margin_origin: [0.0; 2], | |
| 236 | color: None, | |
| 237 | definitions: BTreeMap::new(), | |
| 238 | objects, | |
| 239 | }, | |
| 240 | engine, | |
| 241 | ) | |
| 242 | .unwrap() | |
| 222 | // An empty editor's page is a page with every property at its default. | |
| 223 | let blank = TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]); | |
| 224 | let mut page = CanvasEditor::new(engine, blank.unwrap(), 468.0) | |
| 225 | .unwrap() | |
| 226 | .page() | |
| 227 | .unwrap(); | |
| 228 | page.created = date.map(|date| date.timestamp()); | |
| 229 | page.objects = objects; | |
| 230 | CanvasEditor::from_page(page, engine).unwrap() | |
| 243 | 231 | } else { |
| 244 | 232 | CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), date).unwrap() |
| 245 | 233 | }; |
fuzz/fuzz_targets/offline_model.rs+3| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | #![no_main] |
| 2 | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | 3 | |
| 4 | #[path = "../../crates/onestore/tests/support/ops.rs"] | |
| 5 | mod ops; | |
| 6 | ||
| 4 | 7 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] |
| 5 | 8 | mod model_ops; |
| 6 | 9 | #[path = "../../crates/notebook/tests/support/server.rs"] |
fuzz/fuzz_targets/offline_page.rs+3| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | #![no_main] |
| 2 | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | 3 | |
| 4 | #[path = "../../crates/onestore/tests/support/ops.rs"] | |
| 5 | mod ops; | |
| 6 | ||
| 4 | 7 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
| 5 | 8 | mod disk; |
| 6 | 9 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] |
fuzz/fuzz_targets/protected.rs+15-5| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | 1 | #![no_main] |
| 2 | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | 3 | use onestore::{ |
| 4 | Reference, RevisionIndex, Store, | |
| 5 | protected::{Limits, UnlockedSection}, | |
| 4 | Arena, Reference, RevisionIndex, Section, Store, | |
| 5 | protected::{Key, Limits, UnlockedSection}, | |
| 6 | 6 | }; |
| 7 | 7 | use std::{ops::Range, sync::LazyLock}; |
| 8 | 8 | |
| 9 | static SOURCES: LazyLock<Vec<(Vec<u8>, String, Vec<Range<usize>>)>> = LazyLock::new(|| { | |
| 9 | type Source = (Vec<u8>, String, Vec<Range<usize>>, Key); | |
| 10 | ||
| 11 | static SOURCES: LazyLock<Vec<Source>> = LazyLock::new(|| { | |
| 10 | 12 | [ |
| 11 | 13 | (&include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one")[..], include_str!("../../corpus/native-encrypted/manifest.json")), |
| 12 | 14 | (&include_bytes!("../../corpus/native-protected-boundaries/notebook/synthetic.one")[..], include_str!("../../corpus/native-protected-boundaries/manifest.json")), |
| ... | ... | @@ -21,7 +23,9 @@ static SOURCES: LazyLock<Vec<(Vec<u8>, String, Vec<Range<usize>>)>> = LazyLock:: |
| 21 | 23 | }).collect(); |
| 22 | 24 | ranges.sort_by_key(|range| (range.start, range.end)); |
| 23 | 25 | ranges.dedup(); |
| 24 | (bytes.to_vec(), manifest["password"].as_str().unwrap().to_owned(), ranges) | |
| 26 | let password = manifest["password"].as_str().unwrap().to_owned(); | |
| 27 | let key = Key::open(bytes, &password).unwrap(); | |
| 28 | (bytes.to_vec(), password, ranges, key) | |
| 25 | 29 | }).collect() |
| 26 | 30 | }); |
| 27 | 31 | |
| ... | ... | @@ -29,7 +33,7 @@ fuzz_target!(|data: &[u8]| { |
| 29 | 33 | if data.len() < 8 { |
| 30 | 34 | return; |
| 31 | 35 | } |
| 32 | let (source, password, ranges) = &SOURCES[usize::from(data[0]) % SOURCES.len()]; | |
| 36 | let (source, password, ranges, key) = &SOURCES[usize::from(data[0]) % SOURCES.len()]; | |
| 33 | 37 | let mut bytes = source.clone(); |
| 34 | 38 | let mut password = password.clone(); |
| 35 | 39 | let mode = data[1] % 3; |
| ... | ... | @@ -46,6 +50,12 @@ fuzz_target!(|data: &[u8]| { |
| 46 | 50 | bytes[range.start + offset..range.start + offset + count] |
| 47 | 51 | .copy_from_slice(&data[8..8 + count]); |
| 48 | 52 | } |
| 53 | // The kept-open section decodes the same bytes under the section's key. | |
| 54 | if let Ok(mut section) = Section::unlock(&Arena::default(), bytes.clone(), key) { | |
| 55 | for (space, ..) in section.pages().unwrap_or_default() { | |
| 56 | let _ = section.page(space); | |
| 57 | } | |
| 58 | } | |
| 49 | 59 | let Ok(store) = Store::parse(&bytes) else { |
| 50 | 60 | return; |
| 51 | 61 | }; |
fuzz/fuzz_targets/protected_write.rs+35-38| ... | ... | @@ -1,39 +1,33 @@ |
| 1 | 1 | #![no_main] |
| 2 | //! Chains text edits on a protected section: every written image unlocks with the same | |
| 3 | //! password, reads back as the model predicts and stores none of the new text in clear. | |
| 4 | //! Text typed at the end of a text whose empty final run keeps an insertion style takes | |
| 5 | //! that style, which the page model cannot show, so only its characters are predicted. | |
| 2 | //! Chains text edits on a protected section kept open under its key, now and then writing | |
| 3 | //! it anew under another password: every sealed image opens with the key, reads back as the | |
| 4 | //! model predicts and stores none of the new text in clear. Text typed at the end of a text | |
| 5 | //! whose empty final run keeps an insertion style takes that style, which the page model | |
| 6 | //! cannot show, so only its characters are predicted. | |
| 6 | 7 | use libfuzzer_sys::fuzz_target; |
| 7 | 8 | use onestore::{ |
| 8 | ExGuid, RevisionIndex, Store, | |
| 9 | document::Kind, | |
| 9 | Arena, ExGuid, Section, | |
| 10 | 10 | op::{Edit, Op, PageOp, predict}, |
| 11 | 11 | page::{Page, PageObject}, |
| 12 | protected::{Limits, UnlockedSection}, | |
| 12 | protected::{Key, rekey}, | |
| 13 | 13 | }; |
| 14 | use std::sync::LazyLock; | |
| 14 | 15 | |
| 15 | 16 | const SOURCE: &[u8] = |
| 16 | 17 | include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one"); |
| 17 | const PASSWORD: &str = "fictitious-only"; | |
| 18 | static KEYS: LazyLock<[Key; 2]> = LazyLock::new(|| { | |
| 19 | [ | |
| 20 | Key::open(SOURCE, "fictitious-only").unwrap(), | |
| 21 | Key::new("another fictitious password").unwrap(), | |
| 22 | ] | |
| 23 | }); | |
| 18 | 24 | |
| 19 | /// The first page, and the texts whose empty final run keeps an insertion style. | |
| 20 | fn page(bytes: &[u8]) -> (ExGuid, Page, Vec<ExGuid>) { | |
| 21 | let store = Store::parse(bytes).unwrap(); | |
| 22 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 23 | let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); | |
| 24 | let document = unlocked.document().unwrap(); | |
| 25 | let (space, _) = document.pages().unwrap()[0]; | |
| 26 | let view = &document.spaces[&space]; | |
| 27 | let hidden = view.revisions[&view.contexts[&ExGuid::default()]] | |
| 28 | .nodes | |
| 29 | .iter() | |
| 30 | .filter(|(_, node)| { | |
| 31 | matches!(&node.kind, Kind::RichText { text, runs, .. } | |
| 32 | if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end)) | |
| 33 | }) | |
| 34 | .map(|(id, _)| *id) | |
| 35 | .collect(); | |
| 36 | (space, Page::from_space(&document, space).unwrap(), hidden) | |
| 25 | /// The first page. | |
| 26 | fn page(bytes: &[u8], key: &Key) -> (ExGuid, Page) { | |
| 27 | let arena = Arena::default(); | |
| 28 | let mut section = Section::unlock(&arena, bytes.to_vec(), key).unwrap(); | |
| 29 | let (space, ..) = section.pages().unwrap()[0]; | |
| 30 | (space, section.page(space).unwrap()) | |
| 37 | 31 | } |
| 38 | 32 | |
| 39 | 33 | fn texts(page: &Page) -> Vec<String> { |
| ... | ... | @@ -50,11 +44,20 @@ fn texts(page: &Page) -> Vec<String> { |
| 50 | 44 | |
| 51 | 45 | fuzz_target!(|data: &[u8]| { |
| 52 | 46 | let mut bytes = SOURCE.to_vec(); |
| 47 | let mut key = &KEYS[0]; | |
| 53 | 48 | for (at, step) in (0..).zip(data.chunks(12).take(4)) { |
| 54 | 49 | if step.len() < 4 { |
| 55 | 50 | return; |
| 56 | 51 | } |
| 57 | let (space, mut expected, hidden) = page(&bytes); | |
| 52 | if step[3] == 0xff { | |
| 53 | let other = &KEYS[usize::from(std::ptr::eq(key, &KEYS[0]))]; | |
| 54 | let before = page(&bytes, key).1; | |
| 55 | bytes = rekey(&bytes, Some(key), Some(other)).unwrap(); | |
| 56 | key = other; | |
| 57 | assert_eq!(texts(&page(&bytes, key).1), texts(&before)); | |
| 58 | continue; | |
| 59 | } | |
| 60 | let (space, mut expected) = page(&bytes, key); | |
| 58 | 61 | let candidates: Vec<_> = expected |
| 59 | 62 | .objects |
| 60 | 63 | .iter() |
| ... | ... | @@ -74,7 +77,6 @@ fuzz_target!(|data: &[u8]| { |
| 74 | 77 | "\u{1f512}sealed\u{1f512}{}", |
| 75 | 78 | String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "") |
| 76 | 79 | ); |
| 77 | let styled = hidden.contains(&text.id) && stop == end; | |
| 78 | 80 | let op = PageOp::Text { |
| 79 | 81 | text: text.id, |
| 80 | 82 | range: start..stop, |
| ... | ... | @@ -87,10 +89,10 @@ fuzz_target!(|data: &[u8]| { |
| 87 | 89 | at: 133_000_000_000_000_000 + at * 10_000_000, |
| 88 | 90 | ops: vec![Op::Page { space, op }], |
| 89 | 91 | }; |
| 90 | let store = Store::parse(&bytes).unwrap(); | |
| 91 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 92 | let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); | |
| 93 | let transaction = unlocked.apply("Fuzz", &edit).unwrap(); | |
| 92 | let arena = Arena::default(); | |
| 93 | let mut section = Section::unlock(&arena, bytes.clone(), key).unwrap(); | |
| 94 | section.apply("Fuzz", &edit).unwrap(); | |
| 95 | let transaction = section.seal().unwrap().unwrap(); | |
| 94 | 96 | let mut written = bytes.clone(); |
| 95 | 97 | transaction.apply(&mut written).unwrap(); |
| 96 | 98 | let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect(); |
| ... | ... | @@ -99,12 +101,7 @@ fuzz_target!(|data: &[u8]| { |
| 99 | 101 | .windows(clear.len()) |
| 100 | 102 | .any(|w| w == clear) |
| 101 | 103 | ); |
| 102 | let (_, stored, _) = page(&written); | |
| 103 | if styled { | |
| 104 | assert_eq!(texts(&stored), texts(&expected)); | |
| 105 | } else { | |
| 106 | assert!(stored.objects == expected.objects); | |
| 107 | } | |
| 104 | assert_eq!(texts(&page(&written, key).1), texts(&expected)); | |
| 108 | 105 | bytes = written; |
| 109 | 106 | } |
| 110 | 107 | }); |
platform/linux/ar.sh created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | #!/bin/sh | |
| 2 | exec zig ar "$@" |
platform/linux/cargo.sh created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | #!/bin/sh | |
| 2 | # cargo for Linux from macOS or Linux: `cargo.sh ARCH COMMAND ARGS...`, ARCH x86_64 or aarch64. | |
| 3 | # zig compiles C and links against glibc 2.17, Rust's own floor, which reaches back to RHEL 7, | |
| 4 | # Debian 8 and Ubuntu 14.04. | |
| 5 | set -eu | |
| 6 | here=$(cd "$(dirname "$0")" && pwd) | |
| 7 | case ${1:-} in | |
| 8 | x86_64 | aarch64) ;; | |
| 9 | *) | |
| 10 | echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2 | |
| 11 | exit 2 | |
| 12 | ;; | |
| 13 | esac | |
| 14 | arch=$1 command=$2 | |
| 15 | shift 2 | |
| 16 | command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; } | |
| 17 | triple=$arch-unknown-linux-gnu | |
| 18 | rustup target add "$triple" >/dev/null | |
| 19 | variable=$(echo "$triple" | tr - _) | |
| 20 | env "CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER=$here/cc.sh" \ | |
| 21 | "CC_$variable=$here/cc.sh" "AR_$variable=$here/ar.sh" \ | |
| 22 | ZIG_TARGET="$arch-linux-gnu.2.17" \ | |
| 23 | cargo "$command" --target "$triple" "$@" |
platform/linux/cc.sh created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | #!/bin/sh | |
| 2 | # zig cc for $ZIG_TARGET, as `cargo.sh` sets it: the C compiler and linker. zig rejects | |
| 3 | # rustc's --target, --no-undefined-version and -O1. | |
| 4 | for arg do | |
| 5 | shift | |
| 6 | case $arg in --target=* | -Wl,--no-undefined-version | -Wl,-O1) ;; *) set -- "$@" "$arg" ;; esac | |
| 7 | done | |
| 8 | exec zig cc -target "$ZIG_TARGET" "$@" |
platform/snow-leopard/Cargo.lock+1| ... | ... | @@ -436,6 +436,7 @@ dependencies = [ |
| 436 | 436 | "roxmltree", |
| 437 | 437 | "swash", |
| 438 | 438 | "wgpu", |
| 439 | "windows", | |
| 439 | 440 | ] |
| 440 | 441 | |
| 441 | 442 | [[package]] |
platform/snow-leopard/probe/src/render.rs+2-2| ... | ... | @@ -11,12 +11,12 @@ fn main() { |
| 11 | 11 | fn run() { |
| 12 | 12 | let _context = context::current(); |
| 13 | 13 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); |
| 14 | let target = draw::Target::new(scene::SIZE).unwrap(); | |
| 14 | let target = renderer.target(scene::SIZE).unwrap(); | |
| 15 | 15 | let checker = scene::checker(); |
| 16 | 16 | let primitives = scene::primitives(&checker); |
| 17 | 17 | let started = std::time::Instant::now(); |
| 18 | 18 | renderer.draw(&target, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]).unwrap(); |
| 19 | let pixels = target.read_pixels(); | |
| 19 | let pixels = renderer.read_pixels(&target).unwrap(); | |
| 20 | 20 | println!("drew in {:?}", started.elapsed()); |
| 21 | 21 | let file = std::fs::File::create("render-probe.png").unwrap(); |
| 22 | 22 | let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]); |
platform/snow-leopard/probe/src/text.rs+2-2| ... | ... | @@ -8,13 +8,13 @@ fn main() { |
| 8 | 8 | objc2::rc::autoreleasepool(|_| { |
| 9 | 9 | let _context = context::current(); |
| 10 | 10 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); |
| 11 | let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); | |
| 11 | let target = renderer.target(scene::TEXT_SIZE).unwrap(); | |
| 12 | 12 | let ui = scene::interface(1.0); |
| 13 | 13 | let layers = ui.layers(); |
| 14 | 14 | renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap(); |
| 15 | 15 | let file = std::fs::File::create("text-probe.png").unwrap(); |
| 16 | 16 | let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]); |
| 17 | 17 | encoder.set_color(png::ColorType::Rgba); |
| 18 | encoder.write_header().unwrap().write_image_data(&target.read_pixels()).unwrap(); | |
| 18 | encoder.write_header().unwrap().write_image_data(&renderer.read_pixels(&target).unwrap()).unwrap(); | |
| 19 | 19 | }); |
| 20 | 20 | } |
tools/RELEASE.md+27-5| ... | ... | @@ -20,7 +20,7 @@ development builds, which never update themselves. |
| 20 | 20 | ```text |
| 21 | 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} |
| 22 | 22 | 2026-09-29.r10/ |
| 23 | build.json version, commit, and per platform: file, size, sha256, signature | |
| 23 | build.json version, commit, changes, and per platform: file, size, sha256, signature | |
| 24 | 24 | build.json.sig ed25519 signature of build.json, hex |
| 25 | 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip |
| 26 | 26 | Snowbound-2026-09-29-r10-macos-x86_64.zip |
| ... | ... | @@ -38,6 +38,27 @@ in the immutable `build.json`, whose version the app checks against the one it |
| 38 | 38 | was pointed to, and a forged pointer can only name another signed build, which |
| 39 | 39 | the app ignores unless it is newer than itself. |
| 40 | 40 | |
| 41 | ## What a build changes | |
| 42 | ||
| 43 | `build.json`'s `changes` lists, oldest first, every change the commits on | |
| 44 | `main` after the first published build (`FIRST` in `release.py`) up to this one | |
| 45 | bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`, | |
| 46 | where `version` is the version of the commit that made it. An updating app sums | |
| 47 | the entries newer than itself, so releases it skipped count too, and says "3 | |
| 48 | features, 5 bug fixes, and 2 other changes" with the titles beneath. The list | |
| 49 | is in `build.json` because that is signed; `latest.json` stays a bare pointer. | |
| 50 | ||
| 51 | A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug | |
| 52 | fix, anything else (`docs`, `chore`, no prefix) another change. It counts once, | |
| 53 | titled by its subject without the prefix, unless its body has a top-level | |
| 54 | bulleted list (`- ` or `* ` at the start of a line, wrapped lines indented), | |
| 55 | which counts each item instead, all of the prefix's kind. So a fix is best its | |
| 56 | own small `fix:` commit, and a batch commit should bullet what it brings. | |
| 57 | ||
| 58 | Entries run about 190 bytes, so `build.json` stays under the 1 MiB that apps, | |
| 59 | old ones included, read it within until some 5,000 entries; apps ignore fields | |
| 60 | they don't know, and builds without `changes` read as listing none. | |
| 61 | ||
| 41 | 62 | ## Signing |
| 42 | 63 | |
| 43 | 64 | Every `build.json` and archive is signed with the ed25519 release key in |
| ... | ... | @@ -90,8 +111,8 @@ python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| 90 | 111 | ``` |
| 91 | 112 | |
| 92 | 113 | The script refuses to run unless the working copy matches `main` (a dry run |
| 93 | only warns), runs Clippy (workspace, and `snowbound` without default features) | |
| 94 | and the workspace tests, builds each platform with | |
| 114 | only warns), gates the commit with `tools/ci.py` (see `tools/TESTING.md`), | |
| 115 | builds each platform with | |
| 95 | 116 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), |
| 96 | 117 | `crates/snowbound/linux/package.sh` (taking only its executables) and |
| 97 | 118 | `platform/windows/cargo.sh`, then checks |
| ... | ... | @@ -137,8 +158,9 @@ signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at |
| 137 | 158 | its own version. It stages the update beside the install, so the swap is a rename: |
| 138 | 159 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 139 | 160 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on |
| 140 | Windows). The sync status icon gets a dot, and its popup offers | |
| 141 | Build Folder and Restart to Update. | |
| 161 | Windows). The sync status icon gets a dot, and its popup says what the update | |
| 162 | changes, unfolding to the titles, and offers Build Folder and Restart to | |
| 163 | Update; Check for Updates… lists them in its dialog. | |
| 142 | 164 | |
| 143 | 165 | Restart to Update quits the app and starts the old executable with |
| 144 | 166 | `--finish-update`, which waits for the app to exit, renames the old bundle (or |
tools/TESTING.md+43| ... | ... | @@ -1,5 +1,47 @@ |
| 1 | 1 | # Library regression lanes |
| 2 | 2 | |
| 3 | ## The CI gate | |
| 4 | ||
| 5 | `python3 tools/ci.py` gates `main` (or `--rev REV`, or `--working-copy` for this | |
| 6 | checkout's `@`) in the jj workspace `../snowbound-ci`: it points that | |
| 7 | workspace's own commit, a child of `main`, at the revision's files, so other | |
| 8 | checkouts' edits in progress never reach the result, and a working copy is | |
| 9 | frozen as it was when the run started. Its build cache is | |
| 10 | `../snowbound-ci/target`, apart from every agent's `target/`. Runs wait for one | |
| 11 | another, and the exit status is the result. | |
| 12 | ||
| 13 | | Lane | Runs | | |
| 14 | | --- | --- | | |
| 15 | | `fmt` | `cargo fmt --all --check` | | |
| 16 | | `clippy` | Clippy `-D warnings` on the workspace (all targets and features), and `snowbound` without default features | | |
| 17 | | `test` | builds every test target, runs the executables four at a time (`--test-jobs`) from their package folders, slowest last time first, and the doctests | | |
| 18 | | `python` | builds the examples the suite runs, then `tools/test_*.py` under `uv` with Pillow and pdfplumber | | |
| 19 | | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | | |
| 20 | | `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 | | |
| 21 | | `ios` | `xcodebuild` of the simulator app, unsigned | | |
| 22 | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | | |
| 23 | ||
| 24 | ```sh | |
| 25 | python3 tools/ci.py # every lane, on main | |
| 26 | python3 tools/ci.py --working-copy --changed # the lanes and test packages @'s changes from main reach | |
| 27 | python3 tools/ci.py --rev xyz --lanes test windows # `windows` names both windows-* lanes | |
| 28 | ``` | |
| 29 | ||
| 30 | Lanes run four at a time (`--jobs`), each under its own time limit | |
| 31 | (`--timeout MINUTES` overrides them all). The table it prints names each | |
| 32 | failure's first errors with their files and lines, to tell whose edit broke | |
| 33 | it; `../snowbound-ci/target/ci/runs/TIME/` keeps every lane's log and | |
| 34 | `summary.json` (status, seconds, errors with files, each test executable's | |
| 35 | time), for the last 20 runs. After a run over `--budget` (80 GB), it deletes | |
| 36 | the build units this run didn't use, least recently used first, which keeps | |
| 37 | `deps/` small for the font tests that scan it. Windows needs llvm-mingw from | |
| 38 | `platform/windows/toolchain.sh` in this checkout's `target/windows`, or | |
| 39 | `LLVM_MINGW`; Linux needs `zig`. `release.py` runs the gate on the commit it | |
| 40 | publishes. | |
| 41 | ||
| 42 | The workspace is made on first use; to drop it, `jj workspace forget ci` and | |
| 43 | delete `../snowbound-ci`. | |
| 44 | ||
| 3 | 45 | ## Public fixtures |
| 4 | 46 | |
| 5 | 47 | From a checkout with Rust and [uv](https://docs.astral.sh/uv/), which provides |
| ... | ... | @@ -70,6 +112,7 @@ the run's commands, inputs, outputs and teardown evidence. |
| 70 | 112 | | Boundary | Entry point | Acceptance evidence | |
| 71 | 113 | | --- | --- | --- | |
| 72 | 114 | | Native authoring and cold reopen | `native_runner.py --help` | Independent OneNote capture; exact expected page count when known; owned clone teardown | |
| 115 | | Password-protected sections | `native_protected.py NOTEBOOK PASSWORDS OUTPUT [SECTION]` | A fresh clone cold-opens Snowbound's protected sections, unlocks each in OneNote's dialog, types into one through COM and reads every page with the notebook it leaves (`corpus/protected-sections`) | | |
| 73 | 116 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | |
| 74 | 117 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | |
| 75 | 118 | | SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks | |
tools/canvas/README.md+2| ... | ... | @@ -52,6 +52,8 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a |
| 52 | 52 | |
| 53 | 53 | Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). |
| 54 | 54 | |
| 55 | As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu, or the palette) shows the notebook's recycle bin as its own row of tabs, read-only under a bar: a page's context menu restores it to a section, keeping its identity, copies it out, or deletes it for good, a section's restores it to a folder, and Empty Recycle Bin deletes what it holds (`corpus/recycle-bin-view`). Pages another author changed since they were viewed are bold in the page list, and so are their section's tab and row and the groups and notebook holding them, until left or marked read (Mark as Read, Ctrl+Q off macOS; Mark Notebook as Read; Next Unread; Show Unread Changes in This Notebook); what was read is kept in the cache folder's `read.json`, as OneNote keeps it in its cache. Save As (⇧⌘S) writes the page or section as a OneNote section, the notebook as a OneNote package, or any of them as a PDF; opening a `.onepkg` asks, as Unpack Notebook does, for its name, colour and folder (`corpus/notebook-package`). | |
| 56 | ||
| 55 | 57 | 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. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn. |
| 56 | 58 | |
| 57 | 59 | 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`, `pinch FACTOR`, `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"}`. |
tools/canvas/build_macos.py+3-2| ... | ... | @@ -44,9 +44,10 @@ root = Path(__file__).resolve().parents[2] |
| 44 | 44 | TEAM = '9R7DPNW28H' |
| 45 | 45 | BUNDLE_ID = 'net.paperclover.snowbound' |
| 46 | 46 | CONTAINER = 'iCloud.net.paperclover.snowbound' |
| 47 | # OneNote's sections and tables of contents, which no Mac app declares; OneNote may open them too. | |
| 47 | # OneNote's sections, tables of contents and packages, which no Mac app declares; OneNote may open them too. | |
| 48 | 48 | ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'), |
| 49 | ('com.microsoft.onenote.table-of-contents', 'OneNote Table of Contents', 'onetoc2')] | |
| 49 | ('com.microsoft.onenote.table-of-contents', 'OneNote Table of Contents', 'onetoc2'), | |
| 50 | ('com.microsoft.onenote.package', 'OneNote Package', 'onepkg')] | |
| 50 | 51 | |
| 51 | 52 | |
| 52 | 53 | def developer_id(): |
tools/ci.py created+448| ... | ... | @@ -0,0 +1,448 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Gates a revision (main by default) on formatting, Clippy, the tests, the Python suite and | |
| 3 | every platform's build, checked out in the jj workspace ../snowbound-ci so that edits in | |
| 4 | progress elsewhere never reach it. See tools/TESTING.md.""" | |
| 5 | import argparse | |
| 6 | from concurrent.futures import ThreadPoolExecutor | |
| 7 | from datetime import datetime | |
| 8 | import fcntl | |
| 9 | import json | |
| 10 | import os | |
| 11 | from pathlib import Path | |
| 12 | import re | |
| 13 | import shutil | |
| 14 | import signal | |
| 15 | import subprocess | |
| 16 | import sys | |
| 17 | import threading | |
| 18 | import time | |
| 19 | ||
| 20 | ROOT = Path(__file__).resolve().parents[1] | |
| 21 | CI = ROOT.parent / 'snowbound-ci' | |
| 22 | TARGET = CI / 'target' | |
| 23 | RUNS = TARGET / 'ci' | |
| 24 | # Changes here rebuild every package. | |
| 25 | EVERYTHING = ('Cargo.toml', 'Cargo.lock', 'vendor/', 'rust-toolchain', '.cargo/') | |
| 26 | JSON = '--message-format=json' | |
| 27 | ||
| 28 | ||
| 29 | def cargo(*args): | |
| 30 | return ['cargo', args[0], '--locked', JSON, *args[1:]] | |
| 31 | ||
| 32 | ||
| 33 | def cross(script, arch, *args): | |
| 34 | return ['sh', script, arch, args[0], '--locked', JSON, *args[1:]] | |
| 35 | ||
| 36 | ||
| 37 | def lanes(): | |
| 38 | """Each lane: its commands, the packages whose dependencies (None for all) and the paths | |
| 39 | that select it under --changed, extra environment, and why it can't run here, if so.""" | |
| 40 | clippy = ['clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'] | |
| 41 | # What ships: mobile is iOS's alone, and the tests and lab examples run only on macOS. | |
| 42 | cross_clippy = ['clippy', '--workspace', '--exclude', 'mobile', '--lib', '--bins', '--all-features', | |
| 43 | '--', '-D', 'warnings'] | |
| 44 | windows = CI / 'platform/windows/cargo.sh' | |
| 45 | linux = ROOT / 'platform/linux/cargo.sh' | |
| 46 | mingw = os.environ.get('LLVM_MINGW') or ROOT / 'target/windows/llvm-mingw' | |
| 47 | sdk = Path(os.environ.get('SNOW_LEOPARD_SDK') or ROOT / 'target/snow-leopard/MacOSX10.6.sdk') | |
| 48 | nightly = subprocess.run(['rustup', 'component', 'list', '--installed', '--toolchain', 'nightly'], | |
| 49 | capture_output=True, text=True).stdout.split() | |
| 50 | result = [ | |
| 51 | dict(name='test', minutes=45, packages=None, paths=()), | |
| 52 | dict(name='clippy', minutes=30, packages=None, paths=(), commands=[ | |
| 53 | cargo(*clippy), | |
| 54 | cargo('clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings')]), | |
| 55 | dict(name='fmt', minutes=5, packages=None, paths=(), commands=[['cargo', 'fmt', '--all', '--check']]), | |
| 56 | dict(name='python', minutes=30, packages=None, paths=('tools/', 'corpus/'), commands=[ | |
| 57 | # The suite runs this checkout's examples from target/debug. | |
| 58 | cargo('build', '--workspace', '--all-features', '--examples', '--bins'), | |
| 59 | ['uv', 'run', '--no-project', '--python', '3.12', '--with', 'pillow', '--with', 'pdfplumber', | |
| 60 | 'python', '-m', 'unittest', 'discover', '-s', 'tools', '-p', 'test_*.py']], | |
| 61 | environment={'PYTHONPATH': str(CI / 'tools')}, | |
| 62 | missing=None if shutil.which('uv') else 'needs uv'), | |
| 63 | ] | |
| 64 | for arch in ('x86_64', 'aarch64'): | |
| 65 | result.append(dict( | |
| 66 | name=f'windows-{arch}', minutes=45, packages=['snowbound'], paths=('platform/windows/',), | |
| 67 | # x86_64's nightly Clippy would lint std, which it builds, and nightly's own new lints. | |
| 68 | commands=[*([] if arch == 'x86_64' else [cross(windows, arch, *cross_clippy)]), | |
| 69 | cross(windows, arch, 'build', '-p', 'snowbound')], | |
| 70 | environment={'LLVM_MINGW': str(mingw)}, | |
| 71 | missing=None if arch == 'aarch64' or 'rust-src' in nightly else 'needs nightly with rust-src')) | |
| 72 | for arch in ('x86_64', 'aarch64'): | |
| 73 | result.append(dict( | |
| 74 | name=f'linux-{arch}', minutes=30, packages=['snowbound'], paths=('platform/linux/',), | |
| 75 | commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')], | |
| 76 | environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')}, | |
| 77 | missing=None if shutil.which('zig') else 'needs zig')) | |
| 78 | result.append(dict( | |
| 79 | name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',), | |
| 80 | # build-rust.sh builds the Rust half into target/ios. | |
| 81 | commands=[['xcodebuild', '-quiet', '-project', 'apps/ios/Snowbound.xcodeproj', '-scheme', 'Snowbound', | |
| 82 | '-configuration', 'Debug', '-destination', 'generic/platform=iOS Simulator', | |
| 83 | '-derivedDataPath', TARGET / 'ios-xcode', 'CODE_SIGNING_ALLOWED=NO', 'build']], | |
| 84 | missing=None if shutil.which('xcodebuild') else 'needs Xcode')) | |
| 85 | result.append(dict( | |
| 86 | name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',), | |
| 87 | commands=[['sh', CI / 'platform/snow-leopard/cargo.sh', 'build', '--locked', JSON, '-p', 'snowbound', | |
| 88 | '--no-default-features']], | |
| 89 | environment={'SNOW_LEOPARD_SDK': str(sdk)}, | |
| 90 | missing=('needs nightly with rust-src' if 'rust-src' not in nightly else | |
| 91 | None if sdk.exists() else 'needs the 10.6 SDK from platform/snow-leopard/remote.sh sdk'))) | |
| 92 | return result | |
| 93 | ||
| 94 | ||
| 95 | def jj(*args, cwd=ROOT): | |
| 96 | return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout | |
| 97 | ||
| 98 | ||
| 99 | def checkout(rev): | |
| 100 | """Points the CI workspace's own commit, a child of main, at `rev`'s files.""" | |
| 101 | commits = jj('log', '--no-graph', '-r', rev, '-T', 'commit_id ++ "\\n"').split() | |
| 102 | if len(commits) != 1: | |
| 103 | sys.exit(f'{rev} names {len(commits)} revisions, not one.') | |
| 104 | if not CI.exists(): | |
| 105 | jj('workspace', 'add', '--name', 'ci', '-r', 'main', str(CI)) | |
| 106 | jj('workspace', 'update-stale', cwd=CI) | |
| 107 | jj('rebase', '-r', '@', '-o', 'main', cwd=CI) | |
| 108 | jj('restore', '--from', commits[0], cwd=CI) | |
| 109 | return commits[0] | |
| 110 | ||
| 111 | ||
| 112 | class Run: | |
| 113 | def __init__(self, folder, environment): | |
| 114 | self.folder = folder | |
| 115 | self.environment = environment | |
| 116 | self.processes = set() | |
| 117 | self.lock = threading.Lock() | |
| 118 | self.stopping = False | |
| 119 | ||
| 120 | def execute(self, command, log, deadline, environment=(), cwd=CI): | |
| 121 | """The command's exit status, or None where it outlived `deadline`.""" | |
| 122 | with open(log, 'a') as stream: | |
| 123 | stream.write(f'$ {" ".join(map(str, command))}\n') | |
| 124 | stream.flush() | |
| 125 | try: | |
| 126 | process = subprocess.Popen(list(map(str, command)), cwd=cwd, stdout=stream, | |
| 127 | stderr=subprocess.STDOUT, start_new_session=True, | |
| 128 | env={**self.environment, **dict(environment)}) | |
| 129 | except OSError as error: | |
| 130 | stream.write(f'error: {error}\n') | |
| 131 | return 127 | |
| 132 | with self.lock: | |
| 133 | self.processes.add(process) | |
| 134 | if self.stopping: | |
| 135 | stop(process) | |
| 136 | try: | |
| 137 | return process.wait(timeout=max(deadline - time.monotonic(), 0)) | |
| 138 | except subprocess.TimeoutExpired: | |
| 139 | stop(process) | |
| 140 | stream.write('\n(timed out)\n') | |
| 141 | return None | |
| 142 | finally: | |
| 143 | with self.lock: | |
| 144 | self.processes.discard(process) | |
| 145 | ||
| 146 | def stop_all(self): | |
| 147 | with self.lock: | |
| 148 | self.stopping = True | |
| 149 | for process in self.processes: | |
| 150 | stop(process) | |
| 151 | ||
| 152 | ||
| 153 | def stop(process): | |
| 154 | for sent in (signal.SIGTERM, signal.SIGKILL): | |
| 155 | try: | |
| 156 | os.killpg(process.pid, sent) | |
| 157 | process.wait(timeout=5) | |
| 158 | return | |
| 159 | except (ProcessLookupError, subprocess.TimeoutExpired): | |
| 160 | pass | |
| 161 | ||
| 162 | ||
| 163 | def status(code): | |
| 164 | return 'passed' if code == 0 else 'timeout' if code is None else 'failed' | |
| 165 | ||
| 166 | ||
| 167 | def run_lane(run, lane, deadline): | |
| 168 | log = run.folder / f'{lane["name"]}.log' | |
| 169 | for command in lane['commands']: | |
| 170 | code = run.execute(command, log, deadline, lane.get('environment', {})) | |
| 171 | if code != 0: | |
| 172 | return status(code), [log] | |
| 173 | return 'passed', [log] | |
| 174 | ||
| 175 | ||
| 176 | def run_tests(run, deadline, packages, jobs): | |
| 177 | """Builds every test target, then runs those of `packages` (all where None) side by side, | |
| 178 | each from its package's folder as cargo would, beside the doctests.""" | |
| 179 | log = run.folder / 'test.log' | |
| 180 | code = run.execute(cargo('test', '--workspace', '--all-features', '--no-run'), log, deadline) | |
| 181 | if code != 0: | |
| 182 | return status(code), [log], [] | |
| 183 | binaries = [] | |
| 184 | for line in log.read_text(errors='replace').splitlines(): | |
| 185 | message = json.loads(line) if line.startswith('{') else {} | |
| 186 | if message.get('reason') == 'compiler-artifact' and message['profile']['test'] and message['executable']: | |
| 187 | folder = Path(message['manifest_path']).parent | |
| 188 | if packages is None or folder.name in packages: | |
| 189 | binaries.append((f'{folder.name}.{message["target"]["name"]}', [message['executable']], folder)) | |
| 190 | binaries.append(('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc'], CI)) | |
| 191 | # The slowest last time start first, so that none of them starts last. | |
| 192 | timings = RUNS / 'test-seconds.json' | |
| 193 | seconds = json.loads(timings.read_text()) if timings.exists() else {} | |
| 194 | binaries.sort(key=lambda binary: -seconds.get(binary[0], float('inf'))) | |
| 195 | (run.folder / 'test').mkdir() | |
| 196 | ||
| 197 | def one(binary): | |
| 198 | name, command, cwd = binary | |
| 199 | started = time.monotonic() | |
| 200 | part_log = run.folder / 'test' / f'{name}.log' | |
| 201 | code = run.execute(command, part_log, deadline, cwd=cwd) | |
| 202 | return {'name': name, 'status': status(code), 'seconds': round(time.monotonic() - started, 1), | |
| 203 | 'log': str(part_log)} | |
| 204 | ||
| 205 | with ThreadPoolExecutor(jobs) as pool: | |
| 206 | parts = list(pool.map(one, binaries)) | |
| 207 | timings.write_text(json.dumps({**seconds, **{part['name']: part['seconds'] for part in parts}}, indent=1)) | |
| 208 | failed = [part for part in parts if part['status'] != 'passed'] | |
| 209 | result = 'timeout' if any(part['status'] == 'timeout' for part in failed) else 'failed' if failed else 'passed' | |
| 210 | return result, [log, *(Path(part['log']) for part in failed)], sorted(parts, key=lambda part: -part['seconds']) | |
| 211 | ||
| 212 | ||
| 213 | def relative(path): | |
| 214 | return path.removeprefix(f'{CI}/') | |
| 215 | ||
| 216 | ||
| 217 | def diagnose(log): | |
| 218 | """The errors a log reports, each with the file and line it names where it names one.""" | |
| 219 | errors, text = [], [] | |
| 220 | lines = log.read_text(errors='replace').splitlines() | |
| 221 | test = None | |
| 222 | for index, line in enumerate(lines): | |
| 223 | following = lines[index + 1] if index + 1 < len(lines) else '' | |
| 224 | if line.startswith('{'): | |
| 225 | try: | |
| 226 | message = json.loads(line).get('message') or {} | |
| 227 | except json.JSONDecodeError: | |
| 228 | continue | |
| 229 | spans = [span for span in message.get('spans', []) if span.get('is_primary')] | |
| 230 | if message.get('level') == 'error' and spans: | |
| 231 | errors.append({'error': message['message'], | |
| 232 | 'file': f'{relative(spans[0]["file_name"])}:{spans[0]["line_start"]}'}) | |
| 233 | elif match := re.match(r'---- (\S+) stdout ----', line): | |
| 234 | test = match[1] | |
| 235 | elif match := re.search(r'panicked at (\S+?):(\d+):\d+:$', line): | |
| 236 | errors.append({'error': f'{test or "test"} panicked: {following.strip()}', | |
| 237 | 'file': f'{relative(match[1])}:{match[2]}'}) | |
| 238 | elif match := re.match(r'Diff in (\S+?):(\d+):', line): | |
| 239 | errors.append({'error': 'not formatted (cargo fmt)', 'file': f'{relative(match[1])}:{match[2]}'}) | |
| 240 | elif match := re.match(r'(FAIL|ERROR): (\S+) \((\S+)\)', line): | |
| 241 | block = lines[index + 2:] | |
| 242 | end = next((i for i, rest in enumerate(block) if rest.startswith(('=' * 20, '-' * 20))), len(block)) | |
| 243 | block = [rest for rest in block[:end] if rest.strip()] | |
| 244 | frames = [frame for frame in block if f'File "{CI}/' in frame] | |
| 245 | where = re.search(r'File "(\S+)", line (\d+)', frames[-1]) if frames else None | |
| 246 | errors.append({'error': f'{match[3]}: {block[-1].strip() if block else match[1]}', | |
| 247 | 'file': f'{relative(where[1])}:{where[2]}' if where else None}) | |
| 248 | elif match := re.match(r'(/\S+?):(\d+):\d+: (?:fatal )?error: (.*)', line): | |
| 249 | errors.append({'error': match[3], 'file': f'{relative(match[1])}:{match[2]}'}) | |
| 250 | elif (match := re.match(r'error(?:\[\w+\])?: (.*)', line)) and ( | |
| 251 | where := re.match(r'\s*--> (\S+?):(\d+):\d+', following)): | |
| 252 | errors.append({'error': match[1], 'file': f'{relative(where[1])}:{where[2]}'}) | |
| 253 | elif re.match(r'(error|test \S+ \.\.\. FAILED)', line): | |
| 254 | text.append({'error': line.strip(), 'file': None}) | |
| 255 | if not errors and not text: | |
| 256 | text = [{'error': line, 'file': None} for line in lines if line.strip() and not line.startswith('{')][-3:] | |
| 257 | return list({(error['error'], error['file']): error for error in errors or text}.values()) | |
| 258 | ||
| 259 | ||
| 260 | def affected(changed, metadata): | |
| 261 | """The workspace packages that changed files reach, through what depends on them.""" | |
| 262 | folders = {package['name']: Path(package['manifest_path']).parent.relative_to(CI).as_posix() | |
| 263 | for package in metadata['packages']} | |
| 264 | if any(path.startswith(EVERYTHING) for path in changed): | |
| 265 | return set(folders) | |
| 266 | reached = {name for name, folder in folders.items() if any(path.startswith(folder + '/') for path in changed)} | |
| 267 | users = {package['name']: {dependency['name'] for dependency in package['dependencies'] | |
| 268 | if dependency['name'] in folders} for package in metadata['packages']} | |
| 269 | while more := {name for name, uses in users.items() if uses & reached} - reached: | |
| 270 | reached |= more | |
| 271 | return reached | |
| 272 | ||
| 273 | ||
| 274 | def dependencies(roots, metadata): | |
| 275 | uses = {package['name']: {dependency['name'] for dependency in package['dependencies']} | |
| 276 | for package in metadata['packages']} | |
| 277 | closure, pending = set(), list(roots) | |
| 278 | while pending: | |
| 279 | name = pending.pop() | |
| 280 | if name in uses and name not in closure: | |
| 281 | closure.add(name) | |
| 282 | pending.extend(uses[name]) | |
| 283 | return closure | |
| 284 | ||
| 285 | ||
| 286 | def size(path): | |
| 287 | if not path.is_dir() or path.is_symlink(): | |
| 288 | return path.lstat().st_blocks * 512 | |
| 289 | return sum(file.lstat().st_blocks * 512 for file in path.rglob('*')) | |
| 290 | ||
| 291 | ||
| 292 | def prune(budget, started): | |
| 293 | """Deletes, least recently used first, the build units this run didn't use until the | |
| 294 | target folder fits `budget` bytes. Cargo reads a unit's fingerprint whenever it checks | |
| 295 | it, so the fingerprint's access time is when a build last used the unit.""" | |
| 296 | total = disk_usage() | |
| 297 | if total <= budget: | |
| 298 | return 0 | |
| 299 | units = {} | |
| 300 | for fingerprints in [*TARGET.glob('*/.fingerprint'), *TARGET.glob('*/*/.fingerprint'), | |
| 301 | *TARGET.glob('*/*/*/.fingerprint')]: | |
| 302 | profile = fingerprints.parent | |
| 303 | for kind in ('deps', 'build', '.fingerprint', 'incremental', 'examples'): | |
| 304 | for entry in os.scandir(profile / kind) if (profile / kind).is_dir() else (): | |
| 305 | if match := re.search(r'-([0-9a-f]{16})(?:\.|$)', entry.name): | |
| 306 | units.setdefault((profile, match[1]), []).append(Path(entry.path)) | |
| 307 | used = {} | |
| 308 | for unit, paths in units.items(): | |
| 309 | stamps = [file.stat().st_atime for path in paths if path.parent.name == '.fingerprint' | |
| 310 | for file in path.iterdir()] or [path.stat().st_atime for path in paths] | |
| 311 | used[unit] = max(stamps) | |
| 312 | freed = 0 | |
| 313 | for unit in sorted(units, key=used.get): | |
| 314 | if used[unit] >= started or total - freed <= budget * 0.8: | |
| 315 | break | |
| 316 | for path in units[unit]: | |
| 317 | freed += size(path) | |
| 318 | shutil.rmtree(path) if path.is_dir() and not path.is_symlink() else path.unlink() | |
| 319 | return total - disk_usage() | |
| 320 | ||
| 321 | ||
| 322 | def disk_usage(): | |
| 323 | return int(subprocess.check_output(['du', '-sk', TARGET]).split()[0]) * 1024 | |
| 324 | ||
| 325 | ||
| 326 | def table(results): | |
| 327 | lines = [] | |
| 328 | for result in results: | |
| 329 | seconds = result.get('seconds') | |
| 330 | clock = f'{int(seconds // 60)}:{int(seconds % 60):02}' if seconds is not None else '' | |
| 331 | note = result.get('note', '') | |
| 332 | lines.append(f'{result["name"]:<16} {result["status"]:<8} {clock:>6} {note}'.rstrip()) | |
| 333 | for error in result.get('errors', [])[:6]: | |
| 334 | where = f'{error["file"]} ' if error['file'] else '' | |
| 335 | lines.append(f'{"":<33}{where}{error["error"].splitlines()[0][:160]}') | |
| 336 | if len(result.get('errors', [])) > 6: | |
| 337 | lines.append(f'{"":<33}… {len(result["errors"]) - 6} more in {result["log"]}') | |
| 338 | elif result['status'] in ('failed', 'timeout'): | |
| 339 | lines.append(f'{"":<33}{result["log"]}') | |
| 340 | return '\n'.join(lines) | |
| 341 | ||
| 342 | ||
| 343 | def main(): | |
| 344 | every = lanes() | |
| 345 | names = [lane['name'] for lane in every] | |
| 346 | parser = argparse.ArgumentParser(description=__doc__) | |
| 347 | source = parser.add_mutually_exclusive_group() | |
| 348 | source.add_argument('--rev', default='main', help='The jj revision to gate; main by default') | |
| 349 | source.add_argument('--working-copy', action='store_true', | |
| 350 | help="This checkout's working copy, as it is when the run starts") | |
| 351 | parser.add_argument('--lanes', nargs='+', metavar='LANE', | |
| 352 | help=f'Only these lanes, or those starting LANE-: {", ".join(names)}') | |
| 353 | parser.add_argument('--changed', action='store_true', | |
| 354 | help="Only the lanes, and tests of the packages, that the revision's changes from main reach") | |
| 355 | parser.add_argument('--jobs', type=int, default=4, help='Lanes at once (default 4)') | |
| 356 | parser.add_argument('--test-jobs', type=int, default=4, help='Test executables at once (default 4)') | |
| 357 | parser.add_argument('--timeout', type=float, metavar='MINUTES', help="Each lane's limit, overriding its own") | |
| 358 | parser.add_argument('--budget', type=float, default=80, metavar='GB', | |
| 359 | help='Prune ../snowbound-ci/target to this size after the run (default 80)') | |
| 360 | args = parser.parse_args() | |
| 361 | if ROOT == CI: | |
| 362 | sys.exit(f'Run ci.py from another checkout; {CI} is its own.') | |
| 363 | def named(lane, name): | |
| 364 | return lane == name or lane.startswith(f'{name}-') | |
| 365 | unknown = [name for name in args.lanes or () if not any(named(lane, name) for lane in names)] | |
| 366 | if unknown: | |
| 367 | parser.error(f'unknown lanes {unknown}; choose from {names}') | |
| 368 | chosen = [lane for lane in every if not args.lanes or any(named(lane['name'], name) for name in args.lanes)] | |
| 369 | ||
| 370 | RUNS.mkdir(parents=True, exist_ok=True) | |
| 371 | lock = open(RUNS / 'lock', 'w') | |
| 372 | try: | |
| 373 | fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) | |
| 374 | except BlockingIOError: | |
| 375 | print('Waiting for another ci.py run to finish…', file=sys.stderr, flush=True) | |
| 376 | fcntl.flock(lock, fcntl.LOCK_EX) | |
| 377 | started_at, started = datetime.now().astimezone(), time.monotonic() | |
| 378 | wall = time.time() | |
| 379 | rev = '@' if args.working_copy else args.rev | |
| 380 | commit = checkout(rev) | |
| 381 | described = jj('log', '--no-graph', '-r', commit, '-T', | |
| 382 | 'change_id.short() ++ " " ++ commit_id.short() ++ " " ++ description.first_line()').strip() | |
| 383 | print(f'Gating {rev}: {described}', flush=True) | |
| 384 | ||
| 385 | tested = None | |
| 386 | changed = None | |
| 387 | if args.changed: | |
| 388 | changed = [line for line in jj('diff', '--from', 'main', '--to', commit, '--name-only').splitlines() if line] | |
| 389 | metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=CI)) | |
| 390 | tested = affected(changed, metadata) | |
| 391 | chosen = [lane for lane in chosen | |
| 392 | if any(path.startswith(lane['paths']) for path in changed) | |
| 393 | or tested & (dependencies(lane['packages'], metadata) if lane['packages'] else tested)] | |
| 394 | print(f'Changed from main: {len(changed)} files, reaching {", ".join(sorted(tested)) or "no packages"}', | |
| 395 | flush=True) | |
| 396 | ||
| 397 | folder = RUNS / 'runs' / started_at.strftime('%Y%m%d-%H%M%S') | |
| 398 | folder.mkdir(parents=True) | |
| 399 | environment = {key: value for key, value in os.environ.items() | |
| 400 | if not key.startswith(('ONESTORE_', 'SNOWBOUND_', 'CARGO_TARGET_DIR'))} | |
| 401 | run = Run(folder, environment) | |
| 402 | ||
| 403 | def gate(lane): | |
| 404 | result = {'name': lane['name']} | |
| 405 | if lane.get('missing'): | |
| 406 | print(f'{lane["name"]}: skipped, {lane["missing"]}', flush=True) | |
| 407 | return {**result, 'status': 'skipped', 'note': lane['missing']} | |
| 408 | began = time.monotonic() | |
| 409 | deadline = began + 60 * (args.timeout or lane['minutes']) | |
| 410 | if lane['name'] == 'test': | |
| 411 | outcome, logs, parts = run_tests(run, deadline, tested, args.test_jobs) | |
| 412 | result['parts'] = parts | |
| 413 | else: | |
| 414 | outcome, logs = run_lane(run, lane, deadline) | |
| 415 | errors = [error for log in logs for error in diagnose(log)] if outcome != 'passed' else [] | |
| 416 | if outcome == 'timeout': | |
| 417 | errors.insert(0, {'error': f'timed out after {args.timeout or lane["minutes"]:g} minutes', 'file': None}) | |
| 418 | seconds = round(time.monotonic() - began, 1) | |
| 419 | print(f'{lane["name"]}: {outcome} in {seconds:.0f}s', flush=True) | |
| 420 | return {**result, 'status': outcome, 'seconds': seconds, 'log': str(logs[0]), 'errors': errors} | |
| 421 | ||
| 422 | pool = ThreadPoolExecutor(args.jobs) | |
| 423 | try: | |
| 424 | results = list(pool.map(gate, chosen)) | |
| 425 | except KeyboardInterrupt: | |
| 426 | pool.shutdown(wait=False, cancel_futures=True) | |
| 427 | run.stop_all() | |
| 428 | raise | |
| 429 | passed = all(result['status'] in ('passed', 'skipped') for result in results) | |
| 430 | freed = prune(args.budget * 1e9, wall) | |
| 431 | summary = { | |
| 432 | 'status': 'passed' if passed else 'failed', 'revision': rev, 'commit': commit, 'described': described, | |
| 433 | 'workspace': str(CI), 'started': started_at.isoformat(timespec='seconds'), | |
| 434 | 'seconds': round(time.monotonic() - started, 1), 'changed': changed, | |
| 435 | 'tested_packages': sorted(tested) if tested is not None else None, | |
| 436 | 'pruned_bytes': freed, 'lanes': results, | |
| 437 | } | |
| 438 | (folder / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n') | |
| 439 | for old in sorted((RUNS / 'runs').iterdir())[:-20]: | |
| 440 | shutil.rmtree(old) | |
| 441 | print() | |
| 442 | print(table(results)) | |
| 443 | print(f'\n{summary["status"]} in {summary["seconds"] / 60:.1f} min: {folder / "summary.json"}') | |
| 444 | sys.exit(0 if passed else 1) | |
| 445 | ||
| 446 | ||
| 447 | if __name__ == '__main__': | |
| 448 | main() |
tools/document_model.py+4-8| ... | ... | @@ -1,21 +1,17 @@ |
| 1 | 1 | """Traversal of the exported document's referenced revisions.""" |
| 2 | 2 | import os |
| 3 | 3 | from pathlib import Path |
| 4 | import shutil | |
| 5 | 4 | import subprocess |
| 6 | 5 | |
| 7 | 6 | |
| 8 | 7 | def _exporter(): |
| 9 | """The document exporter built with protected sections, copied aside because a workspace | |
| 10 | build without the feature overwrites target/debug/examples/document.""" | |
| 8 | """The document exporter, built for this run.""" | |
| 11 | 9 | if 'ONESTORE_DOCUMENT' in os.environ: |
| 12 | 10 | return Path(os.environ['ONESTORE_DOCUMENT']) |
| 13 | 11 | root = Path(__file__).resolve().parent.parent |
| 14 | subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--features', 'protected', | |
| 15 | '--example', 'document'], cwd=root, check=True) | |
| 16 | protected = root / 'target/debug/examples/document-protected' | |
| 17 | shutil.copy2(root / 'target/debug/examples/document', protected) | |
| 18 | return protected | |
| 12 | subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--example', 'document'], | |
| 13 | cwd=root, check=True) | |
| 14 | return root / 'target/debug/examples/document' | |
| 19 | 15 | |
| 20 | 16 | |
| 21 | 17 | EXPORTER = _exporter() |
tools/native/protected.ps1 created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [Parameter(Mandatory=$true)][string]$Section, | |
| 4 | [switch]$Edit | |
| 5 | ) | |
| 6 | # Shows the section file $Section of the run's notebook in OneNote, or with -Edit appends a | |
| 7 | # paragraph to the first outline of its first page with one, as an add-in may in an unlocked | |
| 8 | # section. | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' | |
| 12 | $app = New-Object -ComObject OneNote.Application | |
| 13 | $id = '' | |
| 14 | $app.OpenHierarchy((Join-Path (Join-Path $Root 'notebook') $Section), '', [ref]$id, 0) | |
| 15 | if (-not $Edit) { | |
| 16 | $app.NavigateTo($id, '', $false) | |
| 17 | Write-Output $id | |
| 18 | exit 0 | |
| 19 | } | |
| 20 | $hierarchy = '' | |
| 21 | $app.GetHierarchy($id, 4, [ref]$hierarchy, 1) | |
| 22 | [xml]$tree = $hierarchy | |
| 23 | $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) | |
| 24 | if ($pages.Count -eq 0) { throw 'The section shows no pages: it is still locked.' } | |
| 25 | $xml = $null | |
| 26 | $children = $null | |
| 27 | foreach ($page in $pages) { | |
| 28 | $content = '' | |
| 29 | $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 0, 1) | |
| 30 | [xml]$xml = $content | |
| 31 | $children = $xml.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]') | |
| 32 | if ($children) { break } | |
| 33 | } | |
| 34 | if (-not $children) { throw 'No page of the section has an outline to type into.' } | |
| 35 | $element = $xml.CreateElement('one', 'OE', $namespace) | |
| 36 | $text = $xml.CreateElement('one', 'T', $namespace) | |
| 37 | [void]$text.AppendChild($xml.CreateCDataSection('Typed by OneNote 2010 under the key.')) | |
| 38 | [void]$element.AppendChild($text) | |
| 39 | [void]$children.AppendChild($element) | |
| 40 | $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false) | |
| 41 | Start-Sleep -Seconds 15 | |
| 42 | Write-Output 'edited' |
tools/native_protected.py created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | """Cold-open a notebook whose sections Snowbound protected in a fresh OneNote 2010, unlock each | |
| 2 | through OneNote's Protected Section dialog with its password, type into one through COM as an | |
| 3 | add-in may, and read every page back with the notebook OneNote leaves. | |
| 4 | ||
| 5 | Usage: native_protected.py NOTEBOOK_DIR PASSWORDS_JSON OUTPUT_DIR [EDITED_SECTION] | |
| 6 | """ | |
| 7 | import base64 | |
| 8 | import json | |
| 9 | import sys | |
| 10 | from pathlib import Path | |
| 11 | ||
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 13 | import native_runner as runner | |
| 14 | ||
| 15 | windows = runner.windows | |
| 16 | ROOT = Path(__file__).resolve().parent.parent | |
| 17 | REMOTE = 'C:\\one-tests\\runs\\capture' | |
| 18 | ||
| 19 | ||
| 20 | def unlock_script(password): | |
| 21 | return '''WinActivate("ahk_class Framework::CFrame") | |
| 22 | WinWaitActive("ahk_class Framework::CFrame", , 30) | |
| 23 | Sleep(2000) | |
| 24 | Send("{Enter}") | |
| 25 | if !WinWait("Protected Section", , 15) | |
| 26 | throw Error("No Protected Section dialog") | |
| 27 | Sleep(500) | |
| 28 | SendText("%s") | |
| 29 | Send("{Enter}") | |
| 30 | Sleep(4000) | |
| 31 | FileAppend(WinGetTitle("A"), "*") | |
| 32 | ''' % password.replace('"', '""') | |
| 33 | ||
| 34 | ||
| 35 | def interaction(passwords, edited): | |
| 36 | def run(name, output): | |
| 37 | result = windows.do_put(ROOT / 'tools/native/protected.ps1', 'C:\\one-tests\\protected.ps1', name) | |
| 38 | if result.get('error'): | |
| 39 | raise RuntimeError(result['error']) | |
| 40 | script = 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\protected.ps1 -Root %s -Section "%s"' | |
| 41 | for section, password in passwords.items(): | |
| 42 | if password is None: | |
| 43 | continue | |
| 44 | runner.command(name, script % (REMOTE, section), output) | |
| 45 | result = windows.do_exec(unlock_script(password), target=name, timeout_ms=60000, shot_delay_ms=1000) | |
| 46 | stem = Path(section).stem | |
| 47 | (output / f'unlock-{stem}.json').write_text(json.dumps({k: v for k, v in result.items() if k != 'png_b64'}, indent=2)) | |
| 48 | if result.get('png_b64'): | |
| 49 | (output / f'unlock-{stem}.png').write_bytes(base64.b64decode(result['png_b64'])) | |
| 50 | if result.get('error') or result.get('exit') != 0: | |
| 51 | raise RuntimeError(f'Unlocking {section} failed; inspect unlock-{stem}.json.') | |
| 52 | if edited: | |
| 53 | runner.command(name, (script + ' -Edit') % (REMOTE, edited), output, 120000) | |
| 54 | return run | |
| 55 | ||
| 56 | ||
| 57 | if __name__ == '__main__': | |
| 58 | if len(sys.argv) not in (4, 5): | |
| 59 | raise SystemExit(__doc__) | |
| 60 | notebook, passwords, output = Path(sys.argv[1]), json.loads(Path(sys.argv[2]).read_text()), Path(sys.argv[3]) | |
| 61 | edited = sys.argv[4] if len(sys.argv) == 5 else None | |
| 62 | runner.capture(notebook, output, collect_notebook=True, interaction=interaction(passwords, edited)) | |
| 63 | print('Captured native evidence in', output) |
tools/release.py+62-11| ... | ... | @@ -6,6 +6,7 @@ import hashlib |
| 6 | 6 | import json |
| 7 | 7 | import os |
| 8 | 8 | from pathlib import Path |
| 9 | import re | |
| 9 | 10 | import shutil |
| 10 | 11 | import subprocess |
| 11 | 12 | import sys |
| ... | ... | @@ -21,16 +22,14 @@ PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'lin |
| 21 | 22 | 'windows-x86_64', 'windows-aarch64'] |
| 22 | 23 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. |
| 23 | 24 | WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} |
| 24 | CHECKS = [ | |
| 25 | ['cargo', 'clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'], | |
| 26 | ['cargo', 'clippy', '-p', 'snowbound', '--no-default-features', '--', '-D', 'warnings'], | |
| 27 | ['cargo', 'test', '--workspace', '--all-features'], | |
| 28 | ] | |
| 29 | 25 | # Clover's Developer ID Application certificate, by its SHA-1 hash: its name is the account |
| 30 | 26 | # holder's legal name, which nothing here prints or stores. |
| 31 | 27 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' |
| 32 | 28 | # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. |
| 33 | 29 | NOTARY = Path('~/.config/snowbound/notary.json').expanduser() |
| 30 | # The first published build's commit: no client runs anything older, so changes start after it. | |
| 31 | FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781' | |
| 32 | KINDS = {'feat': 'feature', 'fix': 'fix'} | |
| 34 | 33 | |
| 35 | 34 | |
| 36 | 35 | def derive(release, commits): |
| ... | ... | @@ -79,9 +78,60 @@ def run(command, **kwargs): |
| 79 | 78 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) |
| 80 | 79 | |
| 81 | 80 | |
| 82 | def commit_times(revset): | |
| 83 | template = 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\n"' | |
| 84 | return [datetime.fromisoformat(line) for line in jj('log', '--no-graph', '-r', revset, '-T', template).split()] | |
| 81 | def history(): | |
| 82 | """Every commit on `main` by id: its parents' ids, when it was made, and its message.""" | |
| 83 | template = ('commit_id ++ "\\x1f" ++ parents.map(|parent| parent.commit_id()).join(" ") ++ "\\x1f" ++ ' | |
| 84 | 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\x1f" ++ description ++ "\\x1e"') | |
| 85 | commits = {} | |
| 86 | for record in jj('log', '--no-graph', '-r', '::main', '-T', template).split('\x1e')[:-1]: | |
| 87 | commit, parents, made, description = record.split('\x1f') | |
| 88 | commits[commit] = (parents.split(), datetime.fromisoformat(made), description) | |
| 89 | return commits | |
| 90 | ||
| 91 | ||
| 92 | def ancestors(commits, commit): | |
| 93 | """`commit` and every commit before it.""" | |
| 94 | found, stack = set(), [commit] | |
| 95 | while stack: | |
| 96 | commit = stack.pop() | |
| 97 | if commit not in found: | |
| 98 | found.add(commit) | |
| 99 | stack.extend(commits[commit][0]) | |
| 100 | return found | |
| 101 | ||
| 102 | ||
| 103 | def version_of(commits, commit): | |
| 104 | return derive(commits[commit][1], [commits[each][1] for each in ancestors(commits, commit)]) | |
| 105 | ||
| 106 | ||
| 107 | def entries(description): | |
| 108 | """What a commit brings, as (kind, title): one entry of its prefix's kind, or one per item where | |
| 109 | its body has a top-level bulleted list, each of the prefix's kind.""" | |
| 110 | subject, _, body = description.strip().partition('\n') | |
| 111 | prefix = re.match(r'(\w+)(\([^)]*\))?!?:\s*', subject) | |
| 112 | kind = KINDS.get(prefix[1].lower(), 'other') if prefix else 'other' | |
| 113 | items, open_item = [], False | |
| 114 | for line in body.splitlines(): | |
| 115 | if line.startswith(('- ', '* ')): | |
| 116 | items.append(line[2:].strip()) | |
| 117 | open_item = True | |
| 118 | elif open_item and line[:1].isspace() and line.strip(): | |
| 119 | items[-1] += ' ' + line.strip() | |
| 120 | else: | |
| 121 | open_item = False | |
| 122 | titles = [title.rstrip('.') for title in items or [subject[prefix.end():] if prefix else subject]] | |
| 123 | # Capitalized as a sentence, except a word like macOS or iCloud. | |
| 124 | return [(kind, title if re.match(r'\S+[A-Z]', title) else title[:1].upper() + title[1:]) | |
| 125 | for title in titles if title] | |
| 126 | ||
| 127 | ||
| 128 | def changes(commits, commit): | |
| 129 | """Every entry the commits after FIRST up to `commit` bring, oldest first, each with the version | |
| 130 | of the commit that brought it.""" | |
| 131 | versions = {each: version_of(commits, each) | |
| 132 | for each in ancestors(commits, commit) - ancestors(commits, FIRST)} | |
| 133 | return [{'version': name(versions[each]), 'kind': kind, 'title': title} | |
| 134 | for each in sorted(versions, key=versions.get) for kind, title in entries(commits[each][2])] | |
| 85 | 135 | |
| 86 | 136 | |
| 87 | 137 | def sign(files): |
| ... | ... | @@ -164,7 +214,8 @@ def main(): |
| 164 | 214 | |
| 165 | 215 | commit = jj('log', '--no-graph', '-r', 'main', '-T', 'commit_id').strip() |
| 166 | 216 | clean(args.dry_run, 'to release it') |
| 167 | version = derive(commit_times('main')[0], commit_times('::main')) | |
| 217 | commits = history() | |
| 218 | version = version_of(commits, commit) | |
| 168 | 219 | print(f'Snowbound build {version[0]} revision {version[1]}, commit {commit}', flush=True) |
| 169 | 220 | |
| 170 | 221 | published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED |
| ... | ... | @@ -177,8 +228,7 @@ def main(): |
| 177 | 228 | sys.exit(f'{target} holds commit {build["commit"]}, not {commit}.') |
| 178 | 229 | print(f'{target} is already published.') |
| 179 | 230 | else: |
| 180 | for check in CHECKS: | |
| 181 | run(check) | |
| 231 | run([sys.executable, ROOT / 'tools/ci.py', '--rev', commit]) | |
| 182 | 232 | clean(args.dry_run, 'after the checks') |
| 183 | 233 | stage = ROOT / 'target/release-stage' / name(version) |
| 184 | 234 | shutil.rmtree(stage, ignore_errors=True) |
| ... | ... | @@ -208,6 +258,7 @@ def main(): |
| 208 | 258 | 'version': name(version), |
| 209 | 259 | 'commit': commit, |
| 210 | 260 | 'published': datetime.now(ZONE).isoformat(timespec='seconds'), |
| 261 | 'changes': changes(commits, commit), | |
| 211 | 262 | 'archives': {platform: { |
| 212 | 263 | 'file': file.name, |
| 213 | 264 | 'size': file.stat().st_size, |
tools/test_notebook_package.py created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | """VM-free gate for corpus/notebook-package: a package Snowbound packs is a cabinet OneNote 2010 | |
| 2 | unpacks and opens, the notebook Snowbound unpacks cold-opens in OneNote as written, and | |
| 3 | OneNote's own LZX package lists the files Snowbound unpacks.""" | |
| 4 | ||
| 5 | import pathlib | |
| 6 | import struct | |
| 7 | import unittest | |
| 8 | import xml.etree.ElementTree as ElementTree | |
| 9 | import zlib | |
| 10 | ||
| 11 | ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "notebook-package" | |
| 12 | ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}" | |
| 13 | ||
| 14 | ||
| 15 | def cabinet(data): | |
| 16 | """The compression of each folder and each file's name and bytes, read independently of | |
| 17 | Snowbound's reader: MS-CAB's header, folders, files and MSZIP data blocks.""" | |
| 18 | signature, _, size, _, files_at, _, minor, major, folders, files, flags = struct.unpack_from( | |
| 19 | "<4sIIIIIBBHHH", data | |
| 20 | ) | |
| 21 | assert signature == b"MSCF" and size == len(data) and (major, minor) == (1, 3) and flags == 0 | |
| 22 | folder_list = [] | |
| 23 | for index in range(folders): | |
| 24 | start, blocks, compression = struct.unpack_from("<IHH", data, 36 + 8 * index) | |
| 25 | folder_list.append((start, blocks, compression)) | |
| 26 | entries, at = [], files_at | |
| 27 | for _ in range(files): | |
| 28 | length, offset, folder, _, _, _ = struct.unpack_from("<IIHHHH", data, at) | |
| 29 | end = data.index(b"\0", at + 16) | |
| 30 | entries.append((data[at + 16 : end].decode(), offset, length, folder)) | |
| 31 | at = end + 1 | |
| 32 | unpacked = [] | |
| 33 | for start, blocks, compression in folder_list: | |
| 34 | stream, at, history = b"", start, b"" | |
| 35 | for _ in range(blocks): | |
| 36 | _, compressed, _ = struct.unpack_from("<IHH", data, at) | |
| 37 | block = data[at + 8 : at + 8 + compressed] | |
| 38 | at += 8 + compressed | |
| 39 | if compression == 1: | |
| 40 | assert block[:2] == b"CK" | |
| 41 | inflate = zlib.decompressobj(-15, zdict=history) if history else zlib.decompressobj(-15) | |
| 42 | out = inflate.decompress(block[2:]) | |
| 43 | history = (history + out)[-32768:] | |
| 44 | else: | |
| 45 | out = block | |
| 46 | stream += out | |
| 47 | unpacked.append((compression, stream)) | |
| 48 | return [ | |
| 49 | (name, unpacked[folder][0], unpacked[folder][1][offset : offset + length]) | |
| 50 | for name, offset, length, folder in entries | |
| 51 | ] | |
| 52 | ||
| 53 | ||
| 54 | def names(hierarchy): | |
| 55 | notebook = next(ElementTree.parse(hierarchy).getroot().iter(ONE + "Notebook")) | |
| 56 | ||
| 57 | def walk(node): | |
| 58 | for child in node: | |
| 59 | tag = child.tag.replace(ONE, "") | |
| 60 | if tag in ("Section", "SectionGroup", "Page"): | |
| 61 | yield (tag, child.get("name"), list(walk(child))) | |
| 62 | ||
| 63 | return list(walk(notebook)) | |
| 64 | ||
| 65 | ||
| 66 | EXPECTED = [ | |
| 67 | ("Section", "New Section 1", [("Page", "First", [])]), | |
| 68 | ("SectionGroup", "Group", [("Section", "Inner", [("Page", "Inside", [])])]), | |
| 69 | ] | |
| 70 | ||
| 71 | ||
| 72 | class NotebookPackage(unittest.TestCase): | |
| 73 | def test_snowbound_packs_mszip_files_outside_any_notebook(self): | |
| 74 | files = cabinet((ROOT / "candidate" / "Packed.onepkg").read_bytes()) | |
| 75 | self.assertEqual( | |
| 76 | [name for name, *_ in files], | |
| 77 | ["Open Notebook.onetoc2", "New Section 1.one", "Group\\Open Notebook.onetoc2", "Group\\Inner.one"], | |
| 78 | ) | |
| 79 | for name, compression, data in files: | |
| 80 | self.assertEqual(compression, 1, name) | |
| 81 | self.assertEqual(data[128:148], bytes(20), f"{name} names no ancestor") | |
| 82 | ||
| 83 | def test_onenote_unpacks_snowbounds_package(self): | |
| 84 | self.assertEqual(names(ROOT / "onenote-unpacked" / "hierarchy.xml"), EXPECTED) | |
| 85 | ||
| 86 | def test_snowbounds_unpacked_notebook_cold_opens_as_written(self): | |
| 87 | self.assertEqual(names(ROOT / "cold" / "read" / "hierarchy.xml"), EXPECTED) | |
| 88 | for written in (ROOT / "candidate" / "Unpacked").rglob("*.one*"): | |
| 89 | relative = written.relative_to(ROOT / "candidate" / "Unpacked") | |
| 90 | self.assertEqual(written.read_bytes(), (ROOT / "cold" / "notebook" / relative).read_bytes()) | |
| 91 | ||
| 92 | def test_save_as_copies_open_in_onenote(self): | |
| 93 | root = ROOT / "save-as" | |
| 94 | notebook = ElementTree.parse(root / "cold" / "read" / "hierarchy.xml").getroot() | |
| 95 | sections = { | |
| 96 | section.get("name"): [page.get("name") for page in section.iter(ONE + "Page")] | |
| 97 | for section in notebook.iter(ONE + "Section") | |
| 98 | } | |
| 99 | self.assertEqual(sections, {"Inner copy": ["Inside"], "Inside": ["Inside"]}) | |
| 100 | for written in (root / "candidate").glob("*.one"): | |
| 101 | data = written.read_bytes() | |
| 102 | self.assertEqual(data[128:148], bytes(20), written.name) | |
| 103 | # OneNote adopted them into the folder it opened, rewriting only their headers. | |
| 104 | self.assertEqual(data[1024:], (root / "cold" / "notebook" / written.name).read_bytes()[1024:]) | |
| 105 | ||
| 106 | def test_onenotes_package_is_lzx(self): | |
| 107 | data = (ROOT / "native" / "Lab.onepkg").read_bytes() | |
| 108 | _, compression = struct.unpack_from("<HH", data, 36 + 4) | |
| 109 | self.assertEqual(compression & 0xF, 3, "LZX") | |
| 110 | ||
| 111 | ||
| 112 | if __name__ == "__main__": | |
| 113 | unittest.main() |
tools/test_protected_sections.py created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | """Password-protected sections both ways: OneNote 2010 cold-opens and unlocks sections | |
| 2 | Snowbound protected, edited, gave another password, unprotected and merged, and Snowbound | |
| 3 | reads what OneNote wrote, protecting, changing and removing passwords and typing into them | |
| 4 | (`corpus/protected-sections`).""" | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import re | |
| 8 | import subprocess | |
| 9 | from tempfile import TemporaryDirectory | |
| 10 | import unittest | |
| 11 | import xml.etree.ElementTree as ET | |
| 12 | ||
| 13 | from document_model import EXPORTER | |
| 14 | ||
| 15 | ROOT = Path(__file__).resolve().parent.parent | |
| 16 | FIXTURE = ROOT / 'corpus/protected-sections' | |
| 17 | ONE = '{http://schemas.microsoft.com/office/onenote/2010/onenote}' | |
| 18 | ||
| 19 | ||
| 20 | def runs(value): | |
| 21 | if isinstance(value, dict): | |
| 22 | own = [value['text']] if isinstance(value.get('text'), str) else [] | |
| 23 | return own + [text for item in value.values() if isinstance(item, (dict, list)) for text in runs(item)] | |
| 24 | if isinstance(value, list): | |
| 25 | return [text for item in value for text in runs(item)] | |
| 26 | return [] | |
| 27 | ||
| 28 | ||
| 29 | def plain(text): | |
| 30 | """A native `T` element's text without its HTML spans.""" | |
| 31 | return re.sub(r'<[^>]+>', '', text).replace('\n', ' ').strip() | |
| 32 | ||
| 33 | ||
| 34 | def squeezed(text): | |
| 35 | return re.sub(r'\s', '', text) | |
| 36 | ||
| 37 | ||
| 38 | def clear(text, stored): | |
| 39 | """Whether `stored` holds `text` in the clear, as OneNote stores text: UTF-16 or ASCII.""" | |
| 40 | return text.encode('utf-16-le') in stored or text.encode() in stored | |
| 41 | ||
| 42 | ||
| 43 | class ProtectedSectionsTest(unittest.TestCase): | |
| 44 | def setUp(self): | |
| 45 | self.temporary = TemporaryDirectory() | |
| 46 | self.root = Path(self.temporary.name) | |
| 47 | ||
| 48 | def tearDown(self): | |
| 49 | self.temporary.cleanup() | |
| 50 | ||
| 51 | def export(self, section, password): | |
| 52 | """The text Snowbound reads from `section`, under `password` when it has one.""" | |
| 53 | output = self.root / f'export-{len(list(self.root.iterdir()))}' | |
| 54 | command = [EXPORTER, section, output] | |
| 55 | if password is not None: | |
| 56 | file = self.root / f'password-{len(list(self.root.iterdir()))}' | |
| 57 | file.write_text(password) | |
| 58 | command += ['--password-file', file] | |
| 59 | subprocess.run(command, check=True, capture_output=True) | |
| 60 | return ''.join(runs(json.loads((output / 'text.json').read_text()))) | |
| 61 | ||
| 62 | def native_pages(self): | |
| 63 | """The cold read's paragraph texts by section name.""" | |
| 64 | hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot() | |
| 65 | sections, encrypted = {}, set() | |
| 66 | for section in hierarchy.iter(ONE + 'Section'): | |
| 67 | if section.get('encrypted') == 'true': | |
| 68 | encrypted.add(section.get('name')) | |
| 69 | for page in section.iter(ONE + 'Page'): | |
| 70 | sections[page.get('ID')] = section.get('name') | |
| 71 | found = {} | |
| 72 | for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')): | |
| 73 | page = ET.parse(path).getroot() | |
| 74 | name = sections[page.get('ID')] | |
| 75 | found.setdefault(name, []).extend( | |
| 76 | plain(element.text) for element in page.iter(ONE + 'T') if element.text and plain(element.text)) | |
| 77 | return found, encrypted | |
| 78 | ||
| 79 | def test_onenote_unlocks_and_reads_every_section_snowbound_protected(self): | |
| 80 | passwords = json.loads((FIXTURE / 'candidate/passwords.json').read_text()) | |
| 81 | found, encrypted = self.native_pages() | |
| 82 | self.assertEqual(encrypted, {Path(name).stem for name, password in passwords.items() if password}) | |
| 83 | expected = { | |
| 84 | 'Edited': ['Edited first under the key. Edited second under the key. Edited third under the key.'], | |
| 85 | 'Changed': ['Written under the changed password.'], | |
| 86 | 'Conflicted': ['Ours offline.'], | |
| 87 | } | |
| 88 | for name, password in passwords.items(): | |
| 89 | stem = Path(name).stem | |
| 90 | native = ' '.join(found[stem]) | |
| 91 | self.assertIn('Fictitious positioned outline.', native, stem) | |
| 92 | for text in expected.get(stem, []): | |
| 93 | self.assertIn(text, native, stem) | |
| 94 | stored = (FIXTURE / 'candidate/notebook' / name).read_bytes() | |
| 95 | for text in expected.get(stem, []) + ['Fictitious positioned outline.']: | |
| 96 | self.assertEqual(clear(text, stored), password is None, stem) | |
| 97 | # Snowbound's own read of the candidate holds every paragraph OneNote read but the | |
| 98 | # one OneNote typed after. | |
| 99 | read = self.export(FIXTURE / 'candidate/notebook' / name, password) | |
| 100 | for paragraph in found[stem]: | |
| 101 | if paragraph != 'Typed by OneNote 2010 under the key.': | |
| 102 | self.assertIn(squeezed(paragraph), squeezed(read), stem) | |
| 103 | ||
| 104 | def test_snowbound_reads_what_onenote_wrote_into_its_protected_sections(self): | |
| 105 | passwords = json.loads((FIXTURE / 'candidate/passwords.json').read_text()) | |
| 106 | for name, password in passwords.items(): | |
| 107 | read = self.export(FIXTURE / 'cold/notebook' / name, password) | |
| 108 | self.assertIn('Fictitiouspositionedoutline.', squeezed(read), name) | |
| 109 | if name == 'Edited.one': | |
| 110 | self.assertIn('TypedbyOneNote2010underthekey.', squeezed(read)) | |
| 111 | self.assertIn('Editedthirdunderthekey.', squeezed(read)) | |
| 112 | wrong = subprocess.run([EXPORTER, FIXTURE / 'cold/notebook/Edited.one', '--password-file', | |
| 113 | self.write('Protected gate password')], capture_output=True) | |
| 114 | self.assertNotEqual(wrong.returncode, 0) | |
| 115 | ||
| 116 | def write(self, text): | |
| 117 | file = self.root / 'wrong' | |
| 118 | file.write_text(text) | |
| 119 | return file | |
| 120 | ||
| 121 | def test_onenote_protected_changed_and_removed_sections_read_back(self): | |
| 122 | manifest = json.loads((FIXTURE / 'manifest.json').read_text()) | |
| 123 | for name, password in manifest['native'].items(): | |
| 124 | read = self.export(FIXTURE / 'native' / name, password) | |
| 125 | self.assertIn('Secretwords:secretwordhidden', squeezed(read), name) | |
| 126 | stored = (FIXTURE / 'native' / name).read_bytes() | |
| 127 | self.assertEqual(clear('secretword', stored), password is None, name) | |
| 128 | ||
| 129 | ||
| 130 | if __name__ == '__main__': | |
| 131 | unittest.main() |
tools/test_recycle_bin_view.py created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | """VM-free gate for corpus/recycle-bin-view: OneNote 2010 reads a recycle bin Snowbound wrote, and | |
| 2 | the notebook after pages and a section left it and the rest was emptied, without repairing | |
| 3 | either.""" | |
| 4 | ||
| 5 | import pathlib | |
| 6 | import unittest | |
| 7 | import xml.etree.ElementTree as ElementTree | |
| 8 | ||
| 9 | ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "recycle-bin-view" | |
| 10 | ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}" | |
| 11 | ||
| 12 | ||
| 13 | def tree(state): | |
| 14 | notebook = ElementTree.parse(ROOT / "cold" / state / "read" / "hierarchy.xml").getroot() | |
| 15 | ||
| 16 | def walk(node): | |
| 17 | return [ | |
| 18 | (child.get("name"), [page.get("name") for page in child.findall(ONE + "Page")]) | |
| 19 | if child.tag == ONE + "Section" | |
| 20 | else (child.get("name"), walk(child)) | |
| 21 | for child in node | |
| 22 | if child.tag in (ONE + "Section", ONE + "SectionGroup") | |
| 23 | ] | |
| 24 | ||
| 25 | return walk(next(notebook.iter(ONE + "Notebook")) if notebook.tag != ONE + "Notebook" else notebook) | |
| 26 | ||
| 27 | ||
| 28 | class RecycleBinView(unittest.TestCase): | |
| 29 | def test_onenote_reads_the_bin_snowbound_wrote(self): | |
| 30 | self.assertEqual( | |
| 31 | tree("before"), | |
| 32 | [ | |
| 33 | ("New Section 1", ["Kept"]), | |
| 34 | ( | |
| 35 | "OneNote_RecycleBin", | |
| 36 | [ | |
| 37 | ("Deleted Pages", ["Restored page", "Copied page", "Purged page"]), | |
| 38 | ("Back", ["Restored page"]), | |
| 39 | ("Copied", ["Copied page"]), | |
| 40 | ("Purged", ["Purged page"]), | |
| 41 | ("Restored", ["In a restored section"]), | |
| 42 | ("Emptied", ["In an emptied section"]), | |
| 43 | ], | |
| 44 | ), | |
| 45 | ], | |
| 46 | ) | |
| 47 | ||
| 48 | def test_restored_copied_and_emptied_read_as_meant(self): | |
| 49 | self.assertEqual( | |
| 50 | tree("after"), | |
| 51 | [ | |
| 52 | ("New Section 1", ["Kept", "Restored page", "Copied page"]), | |
| 53 | ("Restored", ["In a restored section"]), | |
| 54 | ("OneNote_RecycleBin", [("Deleted Pages", [])]), | |
| 55 | ], | |
| 56 | ) | |
| 57 | ||
| 58 | def test_onenote_repaired_nothing(self): | |
| 59 | for state in ("before", "after"): | |
| 60 | candidate = ROOT / "candidate" / state | |
| 61 | for written in candidate.rglob("*.one*"): | |
| 62 | relative = written.relative_to(candidate) | |
| 63 | cold = ROOT / "cold" / state / "notebook" / relative | |
| 64 | self.assertEqual(written.read_bytes(), cold.read_bytes(), relative) | |
| 65 | ||
| 66 | ||
| 67 | if __name__ == "__main__": | |
| 68 | unittest.main() |
tools/test_release.py+22| ... | ... | @@ -22,6 +22,28 @@ class ReleaseTest(unittest.TestCase): |
| 22 | 22 | self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10)) |
| 23 | 23 | self.assertEqual(release['folder'](('2026-09-29', 10)), '2026-09-29.r10') |
| 24 | 24 | |
| 25 | def test_a_commit_counts_once_by_its_prefix_or_once_per_bullet(self): | |
| 26 | entries = release['entries'] | |
| 27 | self.assertEqual(entries('fix(update): accept an archive of exactly its size\n\nureq refuses.\n'), | |
| 28 | [('fix', 'Accept an archive of exactly its size')]) | |
| 29 | self.assertEqual(entries('docs: center the row'), [('other', 'Center the row')]) | |
| 30 | self.assertEqual(entries('Initial import'), [('other', 'Initial import')]) | |
| 31 | self.assertEqual(entries('feat!: macOS icon'), [('feature', 'macOS icon')]) | |
| 32 | self.assertEqual( | |
| 33 | entries('feat: icon, builds\n\nIntro.\n\n- macOS ships an icon so Tahoe\n shows it.\n' | |
| 34 | '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'), | |
| 35 | [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')]) | |
| 36 | ||
| 37 | def test_changes_start_after_the_first_build_and_carry_their_commits_versions(self): | |
| 38 | first = release['FIRST'] | |
| 39 | commits = {first: ([], utc('2026-09-30T10:00:00'), 'fix: published first'), | |
| 40 | 'b': ([first], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'), | |
| 41 | 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')} | |
| 42 | self.assertEqual(release['changes'](commits, 'c'), [ | |
| 43 | {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'}, | |
| 44 | {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'}, | |
| 45 | {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}]) | |
| 46 | ||
| 25 | 47 | def test_latest_only_moves_forward(self): |
| 26 | 48 | latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'} |
| 27 | 49 | moved = release['newest'](latest, {'macos-aarch64': {}, 'linux-x86_64': {}, 'macos-10.6': {}}, |
tools/test_rtl_page.py created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 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/rtl-page' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | class RtlPageTest(unittest.TestCase): | |
| 16 | def test_a_right_to_left_page_edited_in_snowbound_reopens_where_it_was_put(self): | |
| 17 | with TemporaryDirectory() as temporary: | |
| 18 | read = Path(temporary) / 'read' | |
| 19 | shutil.copytree(FIXTURE / 'cold/read', read) | |
| 20 | compare(FIXTURE / 'candidate', read) | |
| 21 | page = ET.parse(FIXTURE / 'cold/read/page-000.xml').getroot() | |
| 22 | self.assertEqual(page.find('one:PageSettings', ns).get('RTL'), 'true') | |
| 23 | positions = {''.join(outline.find('.//one:T', ns).itertext()): outline.find('one:Position', ns).attrib | |
| 24 | for outline in page.findall('one:Outline', ns) if outline.find('.//one:T', ns) is not None} | |
| 25 | self.assertEqual((positions['Typed on a right-to-left page']['x'], positions['Fictitious positioned outline.']['x']), | |
| 26 | ('126.0', '180.0')) | |
| 27 | self.assertEqual(len(page.findall('one:InkDrawing', ns)), 3) | |
| 28 | ||
| 29 | ||
| 30 | if __name__ == '__main__': | |
| 31 | unittest.main() |
tools/verify-document.py+10-13| ... | ... | @@ -376,16 +376,13 @@ def compare(notebook, native, versions=None, password_file=None): |
| 376 | 376 | source = space['nodes'][source_page['children'][z]] |
| 377 | 377 | if source['kind']['type'] == 'Ink': |
| 378 | 378 | if child.tag == '{%s}InkDrawing' % ns['one']: |
| 379 | # Stroke coordinates are absolute; left-to-right pages report them | |
| 380 | # relative to the canonical margin origin, right-to-left pages as stored. | |
| 379 | # A drawing lies in the page's frame as outlines do: its strokes plus the | |
| 380 | # offset a move gives it, reported from the canonical margin origin. | |
| 381 | 381 | extent = ink_extent(space, source) |
| 382 | # A moved drawing keeps its strokes and takes an offset. | |
| 383 | extent[0] += source['layout']['x'] or 0.0 | |
| 384 | extent[1] += source['layout']['y'] or 0.0 | |
| 385 | if not source_page['kind']['rtl']: | |
| 386 | for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))): | |
| 387 | origin = source_page['kind']['margin_origin_' + axis] | |
| 388 | extent[index] += canonical_origin - origin if origin is not None else 0 | |
| 382 | for index, axis in enumerate(('x', 'y')): | |
| 383 | origin = source_page['kind']['margin_origin_' + axis] | |
| 384 | canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4 | |
| 385 | extent[index] += (source['layout'][axis] or 0.0) + (canonical_origin - origin if origin is not None else 0) | |
| 389 | 386 | size = child.find('one:Size', ns) |
| 390 | 387 | # Native sizes are one HIMETRIC unit larger than the point extent. |
| 391 | 388 | reported = [float(position.attrib['x']), float(position.attrib['y']), float(size.get('width')) - 72 / 2540, float(size.get('height')) - 72 / 2540] |
| ... | ... | @@ -400,11 +397,11 @@ def compare(notebook, native, versions=None, password_file=None): |
| 400 | 397 | origin = source_page['kind']['margin_origin_' + axis] |
| 401 | 398 | canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4 |
| 402 | 399 | projected = stored_position + (canonical_origin - origin if origin is not None else 0) |
| 403 | if axis == 'x' and source_page['kind']['rtl']: | |
| 404 | assert source['kind']['type'] == 'Outline' and source['layout']['max_width'] is not None, 'RTL positioning requires an outline width' | |
| 400 | if axis == 'x' and source_page['kind']['rtl'] and source['kind']['type'] == 'Outline': | |
| 405 | 401 | assert not any(p['id'] == 0x14001c84 for group in source['extra'] for p in group), 'Explicit RTL outline alignment requires a native control' |
| 406 | # Default RTL outlines keep their right edge when native layout changes width. | |
| 407 | projected += source['layout']['max_width'] - float(child.find('one:Size', ns).get('width')) | |
| 402 | # An outline laid out wider than its stored width keeps its right edge. | |
| 403 | width = source['layout']['max_width'] | |
| 404 | projected -= max(0.0, float(child.find('one:Size', ns).get('width')) - width) if width is not None else 0.0 | |
| 408 | 405 | if abs(projected - native_position) > 0.002: |
| 409 | 406 | geometry.append({'file': str(relative), 'page': ordinal, 'z': z, 'axis': axis, |
| 410 | 407 | 'stored': stored_position, 'origin': origin, 'projected': projected, 'native': native_position}) |