| 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,6 +563,12 @@ dependencies = [ |
| 563 | "syn 3.0.5", | 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 | [[package]] | 572 | [[package]] |
| 567 | name = "byteorder-lite" | 573 | name = "byteorder-lite" |
| 568 | version = "0.1.0" | 574 | version = "0.1.0" |
| ... | @@ -575,6 +581,18 @@ version = "1.12.1" | ... | @@ -575,6 +581,18 @@ version = "1.12.1" |
| 575 | source = "registry+https://github.com/rust-lang/crates.io-index" | 581 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 576 | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | 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 | [[package]] | 596 | [[package]] |
| 579 | name = "calloop" | 597 | name = "calloop" |
| 580 | version = "0.13.0" | 598 | version = "0.13.0" |
| ... | @@ -900,6 +918,12 @@ dependencies = [ | ... | @@ -900,6 +918,12 @@ dependencies = [ |
| 900 | "hybrid-array", | 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 | [[package]] | 927 | [[package]] |
| 904 | name = "digest" | 928 | name = "digest" |
| 905 | version = "0.11.3" | 929 | version = "0.11.3" |
| ... | @@ -987,6 +1011,7 @@ dependencies = [ | ... | @@ -987,6 +1011,7 @@ dependencies = [ |
| 987 | "subsetter", | 1011 | "subsetter", |
| 988 | "swash", | 1012 | "swash", |
| 989 | "wgpu", | 1013 | "wgpu", |
| 1014 | "windows", | ||
| 990 | ] | 1015 | ] |
| 991 | 1016 | ||
| 992 | [[package]] | 1017 | [[package]] |
| ... | @@ -1849,6 +1874,12 @@ dependencies = [ | ... | @@ -1849,6 +1874,12 @@ dependencies = [ |
| 1849 | "twox-hash", | 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 | [[package]] | 1883 | [[package]] |
| 1853 | name = "md-5" | 1884 | name = "md-5" |
| 1854 | version = "0.11.0" | 1885 | version = "0.11.0" |
| ... | @@ -1946,6 +1977,7 @@ dependencies = [ | ... | @@ -1946,6 +1977,7 @@ dependencies = [ |
| 1946 | "serde_json", | 1977 | "serde_json", |
| 1947 | "tempfile", | 1978 | "tempfile", |
| 1948 | "wgpu", | 1979 | "wgpu", |
| 1980 | "zeroize", | ||
| 1949 | ] | 1981 | ] |
| 1950 | 1982 | ||
| 1951 | [[package]] | 1983 | [[package]] |
| ... | @@ -2042,6 +2074,11 @@ dependencies = [ | ... | @@ -2042,6 +2074,11 @@ dependencies = [ |
| 2042 | name = "notebook" | 2074 | name = "notebook" |
| 2043 | version = "0.1.0" | 2075 | version = "0.1.0" |
| 2044 | dependencies = [ | 2076 | dependencies = [ |
| 2077 | "aes-gcm", | ||
| 2078 | "base64 0.22.1", | ||
| 2079 | "cab", | ||
| 2080 | "getrandom 0.4.3", | ||
| 2081 | "hmac", | ||
| 2045 | "libc", | 2082 | "libc", |
| 2046 | "nix", | 2083 | "nix", |
| 2047 | "onestore", | 2084 | "onestore", |
| ... | @@ -2067,6 +2104,12 @@ dependencies = [ | ... | @@ -2067,6 +2104,12 @@ dependencies = [ |
| 2067 | "unicode-segmentation", | 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 | [[package]] | 2113 | [[package]] |
| 2071 | name = "num-traits" | 2114 | name = "num-traits" |
| 2072 | version = "0.2.19" | 2115 | version = "0.2.19" |
| ... | @@ -2747,6 +2790,12 @@ dependencies = [ | ... | @@ -2747,6 +2790,12 @@ dependencies = [ |
| 2747 | "zerovec", | 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 | [[package]] | 2799 | [[package]] |
| 2751 | name = "presser" | 2800 | name = "presser" |
| 2752 | version = "0.3.1" | 2801 | version = "0.3.1" |
| ... | @@ -3392,6 +3441,7 @@ dependencies = [ | ... | @@ -3392,6 +3441,7 @@ dependencies = [ |
| 3392 | "windows-sys 0.61.2", | 3441 | "windows-sys 0.61.2", |
| 3393 | "winit", | 3442 | "winit", |
| 3394 | "zbus", | 3443 | "zbus", |
| 3444 | "zeroize", | ||
| 3395 | ] | 3445 | ] |
| 3396 | 3446 | ||
| 3397 | [[package]] | 3447 | [[package]] |
| ... | @@ -3591,6 +3641,25 @@ dependencies = [ | ... | @@ -3591,6 +3641,25 @@ dependencies = [ |
| 3591 | "zune-jpeg", | 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 | [[package]] | 3663 | [[package]] |
| 3595 | name = "tiny-skia" | 3664 | name = "tiny-skia" |
| 3596 | version = "0.11.4" | 3665 | version = "0.11.4" |
apps/ios/Snowbound.xcodeproj/project.pbxproj-4| ... | @@ -7,14 +7,12 @@ | ... | @@ -7,14 +7,12 @@ |
| 7 | 	objects = { | 7 | 	objects = { |
| 8 | 8 | ||
| 9 | /* Begin PBXBuildFile section */ | 9 | /* Begin PBXBuildFile section */ |
| 10 | 		5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; | ||
| 11 | 		5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; | 10 | 		5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; |
| 12 | 		5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; | 11 | 		5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; |
| 13 | /* End PBXBuildFile section */ | 12 | /* End PBXBuildFile section */ |
| 14 | 13 | ||
| 15 | /* Begin PBXFileReference section */ | 14 | /* Begin PBXFileReference section */ |
| 16 | 		5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; | 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 | 		5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; | 16 | 		5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; |
| 19 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | 17 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
| 20 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; | 18 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; |
| ... | @@ -45,7 +43,6 @@ | ... | @@ -45,7 +43,6 @@ |
| 45 | 			isa = PBXGroup; | 43 | 			isa = PBXGroup; |
| 46 | 			children = ( | 44 | 			children = ( |
| 47 | 				5B0000000000000000000300 /* Snowbound */, | 45 | 				5B0000000000000000000300 /* Snowbound */, |
| 48 | 				5B0000000000000000000201 /* Sample */, | ||
| 49 | 				5B0000000000000000000226 /* Snowbound Guide */, | 46 | 				5B0000000000000000000226 /* Snowbound Guide */, |
| 50 | 				5B0000000000000000000205 /* Snowbound-Tahoe.icon */, | 47 | 				5B0000000000000000000205 /* Snowbound-Tahoe.icon */, |
| 51 | 				5B0000000000000000000202 /* Info.plist */, | 48 | 				5B0000000000000000000202 /* Info.plist */, |
| ... | @@ -126,7 +123,6 @@ | ... | @@ -126,7 +123,6 @@ |
| 126 | 			isa = PBXResourcesBuildPhase; | 123 | 			isa = PBXResourcesBuildPhase; |
| 127 | 			buildActionMask = 2147483647; | 124 | 			buildActionMask = 2147483647; |
| 128 | 			files = ( | 125 | 			files = ( |
| 129 | 				5B0000000000000000000101 /* Sample in Resources */, | ||
| 130 | 				5B0000000000000000000126 /* Snowbound Guide in Resources */, | 126 | 				5B0000000000000000000126 /* Snowbound Guide in Resources */, |
| 131 | 				5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */, | 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,6 +150,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 150 | private let notebooks = NotebooksViewController() | 150 | private let notebooks = NotebooksViewController() |
| 151 | private let pages = PagesViewController() | 151 | private let pages = PagesViewController() |
| 152 | private var showingPage = false | 152 | private var showingPage = false |
| 153 | /// A first launch opened a page by itself. | ||
| 154 | private var landed = false | ||
| 153 | private var background = UIBackgroundTaskIdentifier.invalid | 155 | private var background = UIBackgroundTaskIdentifier.invalid |
| 154 | 156 | ||
| 155 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { | 157 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { |
| ... | @@ -203,8 +205,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -203,8 +205,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 203 | } | 205 | } |
| 204 | return | 206 | return |
| 205 | } | 207 | } |
| 206 | guard let place = Place.saved, place.notebook == notebook.id, | 208 | guard let place = Place.saved else { |
| 207 | let tab = notebook.tabs.first(where: { $0.path == place.section }) | 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 | else { return } | 217 | else { return } |
| 209 | open(tab, of: notebook, page: place.page) | 218 | open(tab, of: notebook, page: place.page) |
| 210 | } | 219 | } |
| ... | @@ -213,7 +222,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -213,7 +222,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 213 | _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil, | 222 | _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil, |
| 214 | then: ((Section) -> Void)? = nil | 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 | return Author.ask(from: split) { [weak self] in | 227 | return Author.ask(from: split) { [weak self] in |
| 218 | self?.open(tab, of: notebook, page: page, reveal: reveal, then: then) | 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,7 +233,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 223 | Section.open(tab, of: notebook) { [weak self] section, problem in | 233 | Section.open(tab, of: notebook) { [weak self] section, problem in |
| 224 | guard let self else { return } | 234 | guard let self else { return } |
| 225 | guard let section else { | 235 | guard let section else { |
| 226 | pages.failed(tab, problem) | 236 | pages.failed(problem) |
| 227 | return | 237 | return |
| 228 | } | 238 | } |
| 229 | pages.load(section) | 239 | pages.load(section) |
| ... | @@ -300,7 +310,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -300,7 +310,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 300 | /// Composition ends and every edit is stored and published while the system allows. | 310 | /// Composition ends and every edit is stored and published while the system allows. |
| 301 | func sceneDidEnterBackground(_ scene: UIScene) { | 311 | func sceneDidEnterBackground(_ scene: UIScene) { |
| 302 | NotificationCenter.default.post(name: PageViewController.leaving, object: nil) | 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 | let sections = Section.all | 315 | let sections = Section.all |
| 305 | guard !sections.isEmpty, background == .invalid else { return } | 316 | guard !sections.isEmpty, background == .invalid else { return } |
| 306 | background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in | 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,6 +81,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 81 | private var origin = CGPoint.zero | 81 | private var origin = CGPoint.zero |
| 82 | private var syncing = false | 82 | private var syncing = false |
| 83 | private var scrolled = false | 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 | /// How far right of the content's corner a page opens. | 89 | /// How far right of the content's corner a page opens. |
| 85 | private var left: CGFloat = 0 | 90 | private var left: CGFloat = 0 |
| 86 | /// The zoom a double tap returns to. | 91 | /// The zoom a double tap returns to. |
| ... | @@ -230,7 +235,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -230,7 +235,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 230 | paper() | 235 | paper() |
| 231 | sync() | 236 | sync() |
| 232 | let skip = margin | 237 | let skip = margin |
| 233 | resting = openingZoom() | 238 | resting = openingZoom(skipping: skip) |
| 234 | zoomScale = resting | 239 | zoomScale = resting |
| 235 | left = skip * resting | 240 | left = skip * resting |
| 236 | home() | 241 | home() |
| ... | @@ -301,12 +306,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -301,12 +306,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 301 | dirty = true | 306 | dirty = true |
| 302 | } | 307 | } |
| 303 | 308 | ||
| 304 | /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's | 309 | /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's |
| 305 | /// 11 pt body text shows at 17 points. | 310 | /// 11 pt body text shows at 17 points; less where that fits the page right of `skip` |
| 306 | private func openingZoom() -> CGFloat { | 311 | /// across the screen. |
| 307 | guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 } | 312 | private func openingZoom(skipping skip: CGFloat) -> CGFloat { |
| 308 | // A page opens readable however far the last one was pinched. | 313 | let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1 |
| 309 | return min(2, max(0.75, zoom)) | 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 | /// The empty page left of its first outline, which a phone skips on opening, keeping | 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,22 +336,114 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 329 | if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } | 336 | if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } |
| 330 | } | 337 | } |
| 331 | 338 | ||
| 332 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } | 339 | /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the |
| 333 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true } | 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 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } | 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 | func scrollViewDidZoom(_ scrollView: UIScrollView) { | 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 | transform() | 433 | transform() |
| 338 | // Scrolling carries the caret and handles along; zooming moves them within the page. | 434 | // Scrolling carries the caret and handles along; zooming moves them within the page. |
| 339 | display.setNeedsSelectionUpdate() | 435 | display.setNeedsSelectionUpdate() |
| 340 | } | 436 | } |
| 341 | 437 | ||
| 342 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { | 438 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { |
| 439 | pinchStart = nil | ||
| 343 | if tapZooming { | 440 | if tapZooming { |
| 344 | tapZooming = false | 441 | tapZooming = false |
| 345 | } else { | 442 | } else { |
| 346 | resting = scale | 443 | // A pinch let go within the detent settles on 100%. |
| 347 | UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey) | 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,8 +793,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 694 | edit(external: true) { sb_delete_backward(handle) } | 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. | 796 | /// Runs a change to the page, telling the system when it did not ask for it; returns |
| 698 | private func edit(external: Bool = false, _ change: () -> Bool) { | 797 | /// whether it changed. |
| 798 | @discardableResult | ||
| 799 | private func edit(external: Bool = false, _ change: () -> Bool) -> Bool { | ||
| 699 | if external { | 800 | if external { |
| 700 | inputDelegate?.selectionWillChange(self) | 801 | inputDelegate?.selectionWillChange(self) |
| 701 | inputDelegate?.textWillChange(self) | 802 | inputDelegate?.textWillChange(self) |
| ... | @@ -712,6 +813,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -712,6 +813,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 712 | } | 813 | } |
| 713 | display.setNeedsSelectionUpdate() | 814 | display.setNeedsSelectionUpdate() |
| 714 | changedSelection() | 815 | changedSelection() |
| 816 | return changed | ||
| 715 | } | 817 | } |
| 716 | 818 | ||
| 717 | /// Tells the bar buttons and the format bar what the selection now has. | 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,6 +838,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 736 | override var inputAccessoryView: UIView? { formatBar } | 838 | override var inputAccessoryView: UIView? { formatBar } |
| 737 | 839 | ||
| 738 | override func becomeFirstResponder() -> Bool { | 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 | guard super.becomeFirstResponder() else { return false } | 845 | guard super.becomeFirstResponder() else { return false } |
| 740 | if let handle { sb_view_focus(handle, true) } | 846 | if let handle { sb_view_focus(handle, true) } |
| 741 | display.isActivated = true | 847 | display.isActivated = true |
| ... | @@ -876,6 +982,37 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -876,6 +982,37 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 876 | /// A conflict page, which shows what is stored and takes no edits. | 982 | /// A conflict page, which shows what is stored and takes no edits. |
| 877 | var readOnly: Bool { handle.map(sb_view_read_only) ?? false } | 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 | /// The title as typed; nil on a page without an editable title. | 1016 | /// The title as typed; nil on a page without an editable title. |
| 880 | var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } | 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,11 +1070,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 933 | /// Selects paragraph `id` and brings it into view. | 1070 | /// Selects paragraph `id` and brings it into view. |
| 934 | func selectParagraph(_ id: String) -> Bool { | 1071 | func selectParagraph(_ id: String) -> Bool { |
| 935 | guard let handle else { return false } | 1072 | guard let handle else { return false } |
| 936 | var found = false | 1073 | let found = edit(external: true) { sb_view_select_paragraph(handle, id) } |
| 937 | edit(external: true) { | ||
| 938 | found = sb_view_select_paragraph(handle, id) | ||
| 939 | return found | ||
| 940 | } | ||
| 941 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } | 1074 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } |
| 942 | return found | 1075 | return found |
| 943 | } | 1076 | } |
| ... | @@ -966,11 +1099,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -966,11 +1099,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 966 | /// Selects the first place `query` occurs and brings it into view. | 1099 | /// Selects the first place `query` occurs and brings it into view. |
| 967 | func find(_ query: String) -> Bool { | 1100 | func find(_ query: String) -> Bool { |
| 968 | guard let handle else { return false } | 1101 | guard let handle else { return false } |
| 969 | var found = false | 1102 | let found = edit(external: true) { sb_view_find(handle, query) } |
| 970 | edit(external: true) { | ||
| 971 | found = sb_view_find(handle, query) | ||
| 972 | return found | ||
| 973 | } | ||
| 974 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } | 1103 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } |
| 975 | return found | 1104 | return found |
| 976 | } | 1105 | } |
| ... | @@ -978,12 +1107,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -978,12 +1107,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 978 | /// Puts the caret at the end of the page title. | 1107 | /// Puts the caret at the end of the page title. |
| 979 | func focusTitle() -> Bool { | 1108 | func focusTitle() -> Bool { |
| 980 | guard let handle else { return false } | 1109 | guard let handle else { return false } |
| 981 | var focused = false | 1110 | return edit(external: true) { sb_view_focus_title(handle) } |
| 982 | edit(external: true) { | ||
| 983 | focused = sb_view_focus_title(handle) | ||
| 984 | return focused | ||
| 985 | } | ||
| 986 | return focused | ||
| 987 | } | 1111 | } |
| 988 | 1112 | ||
| 989 | /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most | 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,6 +78,12 @@ enum ICloud { |
| 78 | /// Looks the folder up off the main thread, as asking iCloud can take a while. | 78 | /// Looks the folder up off the main thread, as asking iCloud can take a while. |
| 79 | static func lookUp() { | 79 | static func lookUp() { |
| 80 | background({ () -> URL? in | 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 | guard | 87 | guard |
| 82 | let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? | 88 | let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? |
| 83 | .appendingPathComponent("Documents", isDirectory: true) | 89 | .appendingPathComponent("Documents", isDirectory: true) |
apps/ios/Snowbound/Lists.swift+102-39| ... | @@ -133,6 +133,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -133,6 +133,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 133 | 133 | ||
| 134 | func reload() { | 134 | func reload() { |
| 135 | guard isViewLoaded else { return } | 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 | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } | 143 | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } |
| 137 | let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] | 144 | let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] |
| 138 | let cloud: [(Location, [Notebook], [Item])] = | 145 | let cloud: [(Location, [Notebook], [Item])] = |
| ... | @@ -186,6 +193,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -186,6 +193,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 186 | reload() | 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 | private func showOnDevice(_ shown: Bool) { | 204 | private func showOnDevice(_ shown: Bool) { |
| 190 | Notebooks.showsOnDevice = shown | 205 | Notebooks.showsOnDevice = shown |
| 191 | rescan() | 206 | rescan() |
| ... | @@ -239,7 +254,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -239,7 +254,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 239 | content.image = UIImage( | 254 | content.image = UIImage( |
| 240 | systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill") | 255 | systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill") |
| 241 | content.imageProperties.tintColor = tab.uiColor | 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 | content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here" | 260 | content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here" |
| 244 | content.textProperties.color = .secondaryLabel | 261 | content.textProperties.color = .secondaryLabel |
| 245 | } | 262 | } |
| ... | @@ -370,7 +387,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -370,7 +387,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 370 | return closed ? nil : problem ?? "" | 387 | return closed ? nil : problem ?? "" |
| 371 | }) { [weak self] problem in | 388 | }) { [weak self] problem in |
| 372 | if let problem { | 389 | if let problem { |
| 373 | self?.refuse(title, problem) | 390 | self?.alert(title, problem) |
| 374 | return | 391 | return |
| 375 | } | 392 | } |
| 376 | notebook.pause() | 393 | notebook.pause() |
| ... | @@ -387,7 +404,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -387,7 +404,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 387 | isFile ? name + "." + folder.pathExtension : name) | 404 | isFile ? name + "." + folder.pathExtension : name) |
| 388 | guard target != folder else { return } | 405 | guard target != folder else { return } |
| 389 | guard !FileManager.default.fileExists(atPath: target.path) else { | 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 | return | 408 | return |
| 392 | } | 409 | } |
| 393 | self?.close(notebook, refusing: "Can’t Rename") { | 410 | self?.close(notebook, refusing: "Can’t Rename") { |
| ... | @@ -395,7 +412,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -395,7 +412,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 395 | try FileManager.default.moveItem(at: folder, to: target) | 412 | try FileManager.default.moveItem(at: folder, to: target) |
| 396 | _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil) | 413 | _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil) |
| 397 | } catch { | 414 | } catch { |
| 398 | self?.refuse("Can’t Rename", error.localizedDescription) | 415 | self?.alert("Can’t Rename", error.localizedDescription) |
| 399 | } | 416 | } |
| 400 | self?.rescan() | 417 | self?.rescan() |
| 401 | } | 418 | } |
| ... | @@ -406,10 +423,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -406,10 +423,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 406 | field.clearButtonMode = .whileEditing | 423 | field.clearButtonMode = .whileEditing |
| 407 | NotificationCenter.default.addObserver( | 424 | NotificationCenter.default.addObserver( |
| 408 | forName: UITextField.textDidChangeNotification, object: field, queue: .main | 425 | forName: UITextField.textDidChangeNotification, object: field, queue: .main |
| 409 | ) { _ in | 426 | ) { _ in rename.isEnabled = Self.namesFile(field) } |
| 410 | let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) | ||
| 411 | rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") | ||
| 412 | } | ||
| 413 | } | 427 | } |
| 414 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) | 428 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 415 | alert.addAction(rename) | 429 | alert.addAction(rename) |
| ... | @@ -417,11 +431,17 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -417,11 +431,17 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 417 | present(alert, animated: true) | 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 | /// Copies the notebook beside itself, named as Files names a duplicate. | 440 | /// Copies the notebook beside itself, named as Files names a duplicate. |
| 421 | private func duplicate(_ folder: URL, of notebook: Notebook) { | 441 | private func duplicate(_ folder: URL, of notebook: Notebook) { |
| 422 | // The copy takes what the open sections have stored. | 442 | // The copy takes what the open sections have stored. |
| 423 | for section in Section.all where section.notebook === notebook && !section.flush(10) { | 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 | let base = folder.deletingPathExtension().lastPathComponent | 446 | let base = folder.deletingPathExtension().lastPathComponent |
| 427 | let ext = folder.pathExtension | 447 | let ext = folder.pathExtension |
| ... | @@ -436,7 +456,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -436,7 +456,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 436 | do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription } | 456 | do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription } |
| 437 | return nil | 457 | return nil |
| 438 | }) { [weak self] problem in | 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 | self?.rescan() | 460 | self?.rescan() |
| 441 | } | 461 | } |
| 442 | } | 462 | } |
| ... | @@ -475,7 +495,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -475,7 +495,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 475 | }) | 495 | }) |
| 476 | alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in | 496 | alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in |
| 477 | do { try FileManager.default.removeItem(at: folder) } catch { | 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 | self?.rescan() | 500 | self?.rescan() |
| 481 | }) | 501 | }) |
| ... | @@ -495,7 +515,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -495,7 +515,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 495 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } | 515 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } |
| 496 | switch item { | 516 | switch item { |
| 497 | case .section(let id, let path): | 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 | case .status(let id): | 520 | case .status(let id): |
| 500 | guard let notebook = notebook(id), notebook.problem != nil else { return false } | 521 | guard let notebook = notebook(id), notebook.problem != nil else { return false } |
| 501 | let spinner = UIActivityIndicatorView(style: .medium) | 522 | let spinner = UIActivityIndicatorView(style: .medium) |
| ... | @@ -532,9 +553,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -532,9 +553,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 532 | guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id), | 553 | guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id), |
| 533 | let tab = notebook.tabs.first(where: { $0.path == path }) | 554 | let tab = notebook.tabs.first(where: { $0.path == path }) |
| 534 | else { return } | 555 | else { return } |
| 556 | if tab.locked { return unlock(tab, of: notebook, wrong: false) } | ||
| 535 | onOpen?(tab, notebook) | 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 | private func add(_ notebook: Notebook) { | 586 | private func add(_ notebook: Notebook) { |
| 539 | Notebooks.add(notebook) | 587 | Notebooks.add(notebook) |
| 540 | openFirstSection(of: notebook) | 588 | openFirstSection(of: notebook) |
| ... | @@ -572,10 +620,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -572,10 +620,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 572 | field.autocapitalizationType = .words | 620 | field.autocapitalizationType = .words |
| 573 | NotificationCenter.default.addObserver( | 621 | NotificationCenter.default.addObserver( |
| 574 | forName: UITextField.textDidChangeNotification, object: field, queue: .main | 622 | forName: UITextField.textDidChangeNotification, object: field, queue: .main |
| 575 | ) { _ in | 623 | ) { _ in create.isEnabled = Self.namesFile(field) } |
| 576 | let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) | ||
| 577 | create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") | ||
| 578 | } | ||
| 579 | } | 624 | } |
| 580 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) | 625 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 581 | alert.addAction(create) | 626 | alert.addAction(create) |
| ... | @@ -587,7 +632,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -587,7 +632,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 587 | let place = inCloud ? ICloud.documents : documentsDirectory | 632 | let place = inCloud ? ICloud.documents : documentsDirectory |
| 588 | guard let folder = place?.appendingPathComponent(name) else { return } | 633 | guard let folder = place?.appendingPathComponent(name) else { return } |
| 589 | guard !FileManager.default.fileExists(atPath: folder.path) else { | 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 | let (date, time) = titleDate() | 637 | let (date, time) = titleDate() |
| 593 | background({ () -> String? in | 638 | background({ () -> String? in |
| ... | @@ -597,14 +642,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -597,14 +642,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 597 | return made ? nil : problem ?? "" | 642 | return made ? nil : problem ?? "" |
| 598 | }) { [weak self] problem in | 643 | }) { [weak self] problem in |
| 599 | guard let self else { return } | 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 | if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } | 646 | if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } |
| 602 | rescan { [weak self] notebook in | 647 | rescan(opening: inCloud ? .icloud(path: name) : .documents(path: name)) |
| 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 | } | ||
| 608 | } | 648 | } |
| 609 | } | 649 | } |
| 610 | 650 | ||
| ... | @@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 621 | return nil | 661 | return nil |
| 622 | }) { [weak self] problem in | 662 | }) { [weak self] problem in |
| 623 | guard let self else { return } | 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 | if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } | 665 | if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } |
| 626 | rescan { [weak self] notebook in | 666 | rescan(opening: .documents(path: Notebooks.guide)) |
| 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 | } | ||
| 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 | private func openFolder() { | 670 | private func openFolder() { |
| 642 | let picker = UIDocumentPickerViewController( | 671 | let picker = UIDocumentPickerViewController( |
| 643 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) | 672 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) |
| ... | @@ -721,6 +750,11 @@ final class PagesViewController: UITableViewController { | ... | @@ -721,6 +750,11 @@ final class PagesViewController: UITableViewController { |
| 721 | /// Opens a page of the section. | 750 | /// Opens a page of the section. |
| 722 | var onOpen: ((Section, String) -> Void)? | 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 | init() { | 758 | init() { |
| 725 | super.init(style: .plain) | 759 | super.init(style: .plain) |
| 726 | } | 760 | } |
| ... | @@ -740,6 +774,25 @@ final class PagesViewController: UITableViewController { | ... | @@ -740,6 +774,25 @@ final class PagesViewController: UITableViewController { |
| 740 | ] | 774 | ] |
| 741 | NotificationCenter.default.addObserver( | 775 | NotificationCenter.default.addObserver( |
| 742 | self, selector: #selector(changed), name: Section.changed, object: nil) | 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 | override func viewWillAppear(_ animated: Bool) { | 798 | override func viewWillAppear(_ animated: Bool) { |
| ... | @@ -751,6 +804,10 @@ final class PagesViewController: UITableViewController { | ... | @@ -751,6 +804,10 @@ final class PagesViewController: UITableViewController { |
| 751 | section = nil | 804 | section = nil |
| 752 | items = [] | 805 | items = [] |
| 753 | title = tab.name | 806 | title = tab.name |
| 807 | if Prototype.sectionStrip, let notebook = strip.notebook { | ||
| 808 | title = notebook.name | ||
| 809 | strip.select(tab.path) | ||
| 810 | } | ||
| 754 | tableView.reloadData() | 811 | tableView.reloadData() |
| 755 | var loading = UIContentUnavailableConfiguration.loading() | 812 | var loading = UIContentUnavailableConfiguration.loading() |
| 756 | loading.text = "Opening…" | 813 | loading.text = "Opening…" |
| ... | @@ -768,7 +825,7 @@ final class PagesViewController: UITableViewController { | ... | @@ -768,7 +825,7 @@ final class PagesViewController: UITableViewController { |
| 768 | navigationController?.setToolbarHidden(true, animated: false) | 825 | navigationController?.setToolbarHidden(true, animated: false) |
| 769 | } | 826 | } |
| 770 | 827 | ||
| 771 | func failed(_ tab: Tab, _ problem: String?) { | 828 | func failed(_ problem: String?) { |
| 772 | var empty = UIContentUnavailableConfiguration.empty() | 829 | var empty = UIContentUnavailableConfiguration.empty() |
| 773 | empty.text = "Can’t Open Section" | 830 | empty.text = "Can’t Open Section" |
| 774 | empty.secondaryText = problem | 831 | empty.secondaryText = problem |
| ... | @@ -778,6 +835,10 @@ final class PagesViewController: UITableViewController { | ... | @@ -778,6 +835,10 @@ final class PagesViewController: UITableViewController { |
| 778 | func load(_ section: Section) { | 835 | func load(_ section: Section) { |
| 779 | self.section = section | 836 | self.section = section |
| 780 | title = section.tab.name | 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 | navigationController?.setToolbarHidden(false, animated: false) | 842 | navigationController?.setToolbarHidden(false, animated: false) |
| 782 | reload() | 843 | reload() |
| 783 | } | 844 | } |
| ... | @@ -816,7 +877,7 @@ final class PagesViewController: UITableViewController { | ... | @@ -816,7 +877,7 @@ final class PagesViewController: UITableViewController { |
| 816 | } | 877 | } |
| 817 | 878 | ||
| 818 | private func delete(_ item: Item) { | 879 | private func delete(_ item: Item) { |
| 819 | section?.delete(item.id) { _ in } | 880 | section?.delete(item.id) |
| 820 | } | 881 | } |
| 821 | 882 | ||
| 822 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } | 883 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } |
| ... | @@ -859,6 +920,8 @@ final class PagesViewController: UITableViewController { | ... | @@ -859,6 +920,8 @@ final class PagesViewController: UITableViewController { |
| 859 | override func tableView( | 920 | override func tableView( |
| 860 | _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath | 921 | _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath |
| 861 | ) -> UISwipeActionsConfiguration? { | 922 | ) -> UISwipeActionsConfiguration? { |
| 923 | // A swipe across the list changes section instead; the context menu deletes. | ||
| 924 | if Prototype.sectionStrip { return nil } | ||
| 862 | let item = items[indexPath.row] | 925 | let item = items[indexPath.row] |
| 863 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in | 926 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in |
| 864 | self?.delete(item) | 927 | self?.delete(item) |
apps/ios/Snowbound/Notebooks.swift+66-44| ... | @@ -30,7 +30,11 @@ enum Author { | ... | @@ -30,7 +30,11 @@ enum Author { |
| 30 | 30 | ||
| 31 | static var name: String? { | 31 | static var name: String? { |
| 32 | get { UserDefaults.standard.string(forKey: key) } | 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 | /// Asks for the user name OneNote shows beside changes, then runs `then`. | 40 | /// Asks for the user name OneNote shows beside changes, then runs `then`. |
| ... | @@ -126,13 +130,22 @@ struct Tab: Decodable, Equatable { | ... | @@ -126,13 +130,22 @@ struct Tab: Decodable, Equatable { |
| 126 | let group: String | 130 | let group: String |
| 127 | let color: [UInt8] | 131 | let color: [UInt8] |
| 128 | let readable: Bool | 132 | let readable: Bool |
| 133 | /// Password protected and not unlocked: `Notebook.unlock` opens it. | ||
| 134 | let locked: Bool | ||
| 129 | /// Not on this device yet; `Notebook` asks iCloud Drive for it. | 135 | /// Not on this device yet; `Notebook` asks iCloud Drive for it. |
| 130 | let downloading: Bool | 136 | let downloading: Bool |
| 131 | /// Why the file could not be read, where retrying or repair may help. | 137 | /// Why the file could not be read, where retrying or repair may help. |
| 132 | let problem: String? | 138 | let problem: String? |
| 133 | 139 | ||
| 134 | var uiColor: UIColor { | 140 | var uiColor: UIColor { UIColor(rgb: color) } |
| 135 | UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) | 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,13 +218,8 @@ final class Notebook { |
| 205 | } | 218 | } |
| 206 | let accessed = url.startAccessingSecurityScopedResource() ? url : nil | 219 | let accessed = url.startAccessingSecurityScopedResource() ? url : nil |
| 207 | let local = onThisDevice(url) | 220 | let local = onThisDevice(url) |
| 208 | var library: OpaquePointer? | 221 | let (library, problem) = coordinatedOpen(url, local: local) |
| 209 | // A coordinated read brings a cloud folder's files down before they are read. | 222 | return (library, accessed, problem, local ? url : nil) |
| 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) | ||
| 215 | case .documents(let path): | 223 | case .documents(let path): |
| 216 | let url = documentsDirectory.appendingPathComponent(path) | 224 | let url = documentsDirectory.appendingPathComponent(path) |
| 217 | let library = sb_library_open(url.path, cacheDirectory.path, true, &error) | 225 | let library = sb_library_open(url.path, cacheDirectory.path, true, &error) |
| ... | @@ -220,12 +228,8 @@ final class Notebook { | ... | @@ -220,12 +228,8 @@ final class Notebook { |
| 220 | guard let url = ICloud.documents?.appendingPathComponent(path) else { | 228 | guard let url = ICloud.documents?.appendingPathComponent(path) else { |
| 221 | return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil) | 229 | return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil) |
| 222 | } | 230 | } |
| 223 | var library: OpaquePointer? | 231 | let (library, problem) = coordinatedOpen(url, local: false) |
| 224 | var coordination: NSError? | 232 | return (library, nil, problem, url) |
| 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) | ||
| 229 | case .path(let path): | 233 | case .path(let path): |
| 230 | let url = URL(fileURLWithPath: path) | 234 | let url = URL(fileURLWithPath: path) |
| 231 | let local = onThisDevice(url) | 235 | let local = onThisDevice(url) |
| ... | @@ -262,6 +266,21 @@ final class Notebook { | ... | @@ -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 | /// Lists the sections again, off the main thread. | 284 | /// Lists the sections again, off the main thread. |
| 266 | func reload(done: @escaping () -> Void) { | 285 | func reload(done: @escaping () -> Void) { |
| 267 | guard let handle else { | 286 | guard let handle else { |
| ... | @@ -323,6 +342,18 @@ final class Notebook { | ... | @@ -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 | /// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere | 357 | /// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere |
| 327 | /// too, as iCloud Drive does. | 358 | /// too, as iCloud Drive does. |
| 328 | private func onThisDevice(_ url: URL) -> Bool { | 359 | private func onThisDevice(_ url: URL) -> Bool { |
| ... | @@ -414,7 +445,7 @@ enum Notebooks { | ... | @@ -414,7 +445,7 @@ enum Notebooks { |
| 414 | set { UserDefaults.standard.set(!newValue, forKey: hidden) } | 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 | static func load() { | 449 | static func load() { |
| 419 | if let scripted { | 450 | if let scripted { |
| 420 | elsewhere = [ | 451 | elsewhere = [ |
| ... | @@ -435,12 +466,6 @@ enum Notebooks { | ... | @@ -435,12 +466,6 @@ enum Notebooks { |
| 435 | return Notebook(id: entry.id, name: entry.name, source: entry.source) | 466 | return Notebook(id: entry.id, name: entry.name, source: entry.source) |
| 436 | } | 467 | } |
| 437 | } else { | 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 | // The notebook the first version remembered from Files. | 469 | // The notebook the first version remembered from Files. |
| 445 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { | 470 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { |
| 446 | var stale = false | 471 | var stale = false |
| ... | @@ -456,34 +481,31 @@ enum Notebooks { | ... | @@ -456,34 +481,31 @@ enum Notebooks { |
| 456 | /// Lists Documents again, keeping the notebooks still there; returns those new to the list. | 481 | /// Lists Documents again, keeping the notebooks still there; returns those new to the list. |
| 457 | @discardableResult | 482 | @discardableResult |
| 458 | static func scan() -> [Notebook] { | 483 | static func scan() -> [Notebook] { |
| 459 | guard showsOnDevice else { | 484 | let folder = showsOnDevice ? documentsDirectory : nil |
| 460 | onDevice = [] | 485 | return rescan(folder, "Documents/", &onDevice) { .documents(path: $0) } |
| 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 | ||
| 472 | } | 486 | } |
| 473 | 487 | ||
| 474 | /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while | 488 | /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while |
| 475 | /// iCloud Drive is off. | 489 | /// iCloud Drive is off. |
| 476 | @discardableResult | 490 | @discardableResult |
| 477 | static func scanICloud() -> [Notebook] { | 491 | static func scanICloud() -> [Notebook] { |
| 478 | guard let folder = ICloud.documents, scripted == nil else { | 492 | rescan(scripted == nil ? ICloud.documents : nil, "iCloud/", &inCloud) { .icloud(path: $0) } |
| 479 | inCloud = [] | 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 | return [] | 502 | return [] |
| 481 | } | 503 | } |
| 482 | var added: [Notebook] = [] | 504 | var added: [Notebook] = [] |
| 483 | inCloud = notebooks(in: folder).map { name in | 505 | list = notebooks(in: folder).map { [list] name in |
| 484 | if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept } | 506 | if let kept = list.first(where: { $0.id == prefix + name }) { return kept } |
| 485 | let notebook = Notebook( | 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 | added.append(notebook) | 509 | added.append(notebook) |
| 488 | return notebook | 510 | return notebook |
| 489 | } | 511 | } |
| ... | @@ -650,13 +672,13 @@ final class Section { | ... | @@ -650,13 +672,13 @@ final class Section { |
| 650 | return id | 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 | let (date, time) = titleDate() | 676 | let (date, time) = titleDate() |
| 655 | let pointer = Int(bitPattern: handle) | 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 | reloadRows() | 679 | reloadRows() |
| 658 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) | 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,6 +21,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 21 | private let titleFocus: Bool | 21 | private let titleFocus: Bool |
| 22 | private lazy var canvas = CanvasView(section: section, page: page) | 22 | private lazy var canvas = CanvasView(section: section, page: page) |
| 23 | private let bar = ConflictBar() | 23 | private let bar = ConflictBar() |
| 24 | /// Reading View, beside the page menu where the page has one. | ||
| 25 | private var reader: UIBarButtonItem? | ||
| 24 | /// Opens another page or conflict page of the section. | 26 | /// Opens another page or conflict page of the section. |
| 25 | var onOpen: ((String) -> Void)? | 27 | var onOpen: ((String) -> Void)? |
| 26 | private lazy var done = UIBarButtonItem( | 28 | private lazy var done = UIBarButtonItem( |
| ... | @@ -96,6 +98,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -96,6 +98,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 96 | if revealed || titleFocus && canvas.focusTitle() { | 98 | if revealed || titleFocus && canvas.focusTitle() { |
| 97 | _ = canvas.becomeFirstResponder() | 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 | private func showTitle(_ title: String) { | 109 | private func showTitle(_ title: String) { |
| ... | @@ -121,8 +129,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -121,8 +129,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 121 | undo.isEnabled = canvas.canUndo(redo: false) | 129 | undo.isEnabled = canvas.canUndo(redo: false) |
| 122 | redo.isEnabled = canvas.canUndo(redo: true) | 130 | redo.isEnabled = canvas.canUndo(redo: true) |
| 123 | // Drawing keeps undo at hand, as typing does. | 131 | // Drawing keeps undo at hand, as typing does. |
| 132 | reader?.isSelected = canvas.readingShown | ||
| 124 | guard canvas.isFirstResponder || canvas.pickerShown else { | 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 | return | 136 | return |
| 127 | } | 137 | } |
| 128 | let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] | 138 | let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] |
| ... | @@ -164,7 +174,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -164,7 +174,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 164 | title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable | 174 | title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable |
| 165 | ) { [weak self] _ in self?.canvas.insertSpace() }, | 175 | ) { [weak self] _ in self?.canvas.insertSpace() }, |
| 166 | ]) | 176 | ]) |
| 167 | return [ | 177 | return (Prototype.reading ? [readingAction()] : []) + [ |
| 168 | insert, | 178 | insert, |
| 169 | UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { | 179 | UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { |
| 170 | [weak self] _ in self?.showPaper() | 180 | [weak self] _ in self?.showPaper() |
| ... | @@ -189,12 +199,27 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -189,12 +199,27 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 189 | UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) { | 199 | UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) { |
| 190 | [weak self] _ in | 200 | [weak self] _ in |
| 191 | guard let self else { return } | 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 | /// The page's theme, its section's and its notebook's, each pickable; the page wears a | 223 | /// The page's theme, its section's and its notebook's, each pickable; the page wears a |
| 199 | /// new one when it opens again. | 224 | /// new one when it opens again. |
| 200 | private func themeMenu() -> UIMenu { | 225 | private func themeMenu() -> UIMenu { |
| ... | @@ -228,12 +253,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -228,12 +253,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 228 | let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) | 253 | let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) |
| 229 | let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" | 254 | let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" |
| 230 | guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { | 255 | guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { |
| 231 | let alert = UIAlertController( | 256 | return alert( |
| 232 | title: export ? "Couldn't Export the PDF" : "Couldn't Print", | 257 | export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.") |
| 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 | ||
| 237 | } | 258 | } |
| 238 | if export { | 259 | if export { |
| 239 | let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") | 260 | let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") |
| ... | @@ -274,12 +295,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -274,12 +295,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 274 | // Nothing typed is lost unannounced: the page's text goes on the clipboard. | 295 | // Nothing typed is lost unannounced: the page's text goes on the clipboard. |
| 275 | UIPasteboard.general.string = canvas.pageText | 296 | UIPasteboard.general.string = canvas.pageText |
| 276 | canvas.reload(discard: true) | 297 | canvas.reload(discard: true) |
| 277 | let alert = UIAlertController( | 298 | alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.") |
| 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) | ||
| 283 | } else if flags & Section.remote != 0 { | 299 | } else if flags & Section.remote != 0 { |
| 284 | canvas.reload(discard: false) | 300 | canvas.reload(discard: false) |
| 285 | } | 301 | } |
| ... | @@ -332,7 +348,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -332,7 +348,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 332 | private func conflictAction() { | 348 | private func conflictAction() { |
| 333 | guard let (row, version) = section.row(of: page) else { return } | 349 | guard let (row, version) = section.row(of: page) else { return } |
| 334 | if version != nil { | 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 | } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { | 352 | } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { |
| 337 | onOpen?(newest.id) | 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,7 +14,7 @@ extension CanvasView { |
| 14 | /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the | 14 | /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the |
| 15 | /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes | 15 | /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes |
| 16 | /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done` | 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 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no | 18 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no |
| 19 | /// screenshot command. | 19 | /// screenshot command. |
| 20 | func runScript() { | 20 | func runScript() { |
| ... | @@ -73,8 +73,9 @@ extension CanvasView { | ... | @@ -73,8 +73,9 @@ extension CanvasView { |
| 73 | from: position(from: beginningOfDocument, offset: values[0])!, | 73 | from: position(from: beginningOfDocument, offset: values[0])!, |
| 74 | to: position(from: beginningOfDocument, offset: values[1])!) | 74 | to: position(from: beginningOfDocument, offset: values[1])!) |
| 75 | case "scroll": | 75 | case "scroll": |
| 76 | let values = argument.split(separator: ",").compactMap { Double($0) } | 76 | // `scroll:,Y` keeps the horizontal offset. |
| 77 | setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) | 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 | case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) | 79 | case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) |
| 79 | case "doubletap": | 80 | case "doubletap": |
| 80 | let values = argument.split(separator: ",").compactMap { Double($0) } | 81 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| ... | @@ -90,6 +91,7 @@ extension CanvasView { | ... | @@ -90,6 +91,7 @@ extension CanvasView { |
| 90 | menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) | 91 | menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) |
| 91 | case "done": _ = resignFirstResponder() | 92 | case "done": _ = resignFirstResponder() |
| 92 | case "selectall": selectAll(nil) | 93 | case "selectall": selectAll(nil) |
| 94 | case "reading": showReading(!readingShown) | ||
| 93 | case "format": apply(UInt8(argument) ?? 0) | 95 | case "format": apply(UInt8(argument) ?? 0) |
| 94 | case "find": _ = find(argument) | 96 | case "find": _ = find(argument) |
| 95 | case "title": _ = focusTitle() | 97 | case "title": _ = focusTitle() |
apps/ios/Snowbound/Server.swift-6| ... | @@ -110,12 +110,6 @@ final class ServerViewController: UITableViewController, UITextFieldDelegate { | ... | @@ -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 | override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } | 113 | override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } |
| 120 | 114 | ||
| 121 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } | 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,6 +84,11 @@ same line heights, same places. That took measuring, not guessing. |
| 84 | Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags | 84 | Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags |
| 85 | sit in a column to the left of the text with OneNote's spacing, and a tag's | 85 | sit in a column to the left of the text with OneNote's spacing, and a tag's |
| 86 | colour paints the whole paragraph as it does there. | 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 | These rules were established against OneNote's own output: its XML export | 93 | These rules were established against OneNote's own output: its XML export |
| 89 | gives outline sizes, its PDF export gives exact line breaks, and screenshots | 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,11 +182,19 @@ tools), while the writer stays strict. |
| 182 | 182 | ||
| 183 | ## Protected sections | 183 | ## Protected sections |
| 184 | 184 | ||
| 185 | Password-protected sections keep their encrypted structure. With the | 185 | A password-protected section keeps its structure in the clear and encrypts every |
| 186 | `protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied | 186 | object and payload under one AES-128 key, which Office's Agile password encryption |
| 187 | password. Edits are sealed back under the section's key, with a fresh IV for | 187 | wraps (SHA-1, 100,000 rounds). `protected::Key` opens it with the password, and |
| 188 | each object. Plaintext never reaches the replica's cache. A protected section is | 188 | `Section::unlock` keeps the section open under it: objects decode into the arena as it |
| 189 | edited directly in its file and never queued. | 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 | ## Looking inside | 199 | ## Looking inside |
| 192 | 200 |
arc/platforms.md+20-8| ... | @@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system. | ... | @@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system. |
| 10 | shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) | 10 | shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) |
| 11 | glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) | 11 | glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) |
| 12 | page canvas ─────────────────────────────────────────────────────────────────────► | 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 | data notebook::session + embedded SMB client ────────────────────────────────────► | 14 | data notebook::session + embedded SMB client ────────────────────────────────────► |
| 15 | format onestore ───────────────────────────────────────────────────────────────────► | 15 | format onestore ───────────────────────────────────────────────────────────────────► |
| 16 | ``` | 16 | ``` |
| ... | @@ -103,9 +103,15 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. | ... | @@ -103,9 +103,15 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 103 | imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 | 103 | imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 |
| 104 | and later that dependencies still name are answered by | 104 | and later that dependencies still name are answered by |
| 105 | `platform/windows/rt/shims.c`; everything newer is looked up at run time. | 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 | Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise | 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 | - The toolbar's row is the title bar wherever the desktop composes windows. | 115 | - The toolbar's row is the title bar wherever the desktop composes windows. |
| 110 | On Windows 7 with Aero and on 11, the system's frame keeps its sides, | 116 | On Windows 7 with Aero and on 11, the system's frame keeps its sides, |
| 111 | its caption gives way to the row, its material (Aero glass, Mica) lies | 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,11 +121,17 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 115 | draws them as 11 does, lit where the system reports the pointer. On 8 | 121 | draws them as 11 does, lit where the system reports the pointer. On 8 |
| 116 | and 10, whose frames are opaque, the window has no system frame and the | 122 | and 10, whose frames are opaque, the window has no system frame and the |
| 117 | row draws and runs caption buttons as 10 does, over acrylic on 10. Over | 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 | 124 | 7's glass, which takes any colour and whatever lies behind the window, |
| 119 | faces, as Internet Explorer's do there. The theme and material follow the | 125 | the toolbar keeps opaque faces under its icons: by default the strip is |
| 120 | system's colour mode as it changes. With Windows 7's basic or classic | 126 | opaque, as Explorer's command bar is under its glass, stopping short of the |
| 121 | theme the system draws the title bar and the row lies beneath it, as on | 127 | caption buttons 7 draws beneath the window's pixels, and |
| 122 | KDE. | 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 | - A notebook on a share opens by its UNC path through Windows' own SMB | 135 | - A notebook on a share opens by its UNC path through Windows' own SMB |
| 124 | client, which takes OneNote's opens and locks natively: `onestore` opens a | 136 | client, which takes OneNote's opens and locks natively: `onestore` opens a |
| 125 | section as OneNote does (a reader shares it with everyone, a writer denies | 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,7 +55,15 @@ reopens the same queue after a relaunch. It holds: |
| 55 | - **payloads**: picture and attachment bytes, stored once each, keyed by hash. | 55 | - **payloads**: picture and attachment bytes, stored once each, keyed by hash. |
| 56 | 56 | ||
| 57 | The working state is never stored. It is the base with each sealed batch | 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 | mode with full synchronous commits, and every setting is read back to check it | 67 | mode with full synchronous commits, and every setting is read back to check it |
| 60 | took. | 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,9 +5,10 @@ table columns to 96 and 144 points through OneNote COM. `before.xml`, `after.xml |
| 5 | and `read/page-000.xml` retain the native observations. The saved store uses | 5 | and `read/page-000.xml` retain the native observations. The saved store uses |
| 6 | visual left-to-right column order [Right cell, Left cell], with widths [144, 96]. | 6 | visual left-to-right column order [Right cell, Left cell], with widths [144, 96]. |
| 7 | COM exports cells in page direction [Left cell, Right cell], with widths [96, 144]. | 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: | 8 | The native PDF independently confirms the physical cell order. Native x is store x - |
| 9 | store x + LayoutMaxWidth - PageMarginOriginX - 36 equals native x + native width. | 9 | PageMarginOriginX - 36; the native table outline measures wider than its stored suggested |
| 10 | The native table outline measures wider than its stored suggested maximum width. | 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 | Vertical coordinates retain the 14.4-point canonical origin. | 12 | Vertical coordinates retain the 14.4-point canonical origin. |
| 12 | 13 | ||
| 13 | The public fixture includes images, a file and two opaque ink objects. All | 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,8 +2,8 @@ |
| 2 | 2 | ||
| 3 | `candidate/notebook` is `native-encrypted/encrypted-01` after | 3 | `candidate/notebook` is `native-encrypted/encrypted-01` after |
| 4 | a Rust edit of the unlocked page appended text to the first paragraph and added a | 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` | 5 | paragraph, exported by the protected writer that `Section::unlock` has since replaced |
| 6 | (`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a | 6 | (`corpus/protected-sections` gates the current one). `candidate/read` is OneNote 2010's COM read from a |
| 7 | fresh clone with an empty cache after unlocking the section in its UI with the | 7 | fresh clone with an empty cache after unlocking the section in its UI with the |
| 8 | fixture password (`../native-encrypted/manifest.json`). | 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,6 +46,10 @@ wgpu.workspace = true |
| 46 | name = "render_page" | 46 | name = "render_page" |
| 47 | required-features = ["gpu"] | 47 | required-features = ["gpu"] |
| 48 | 48 | ||
| 49 | [[example]] | ||
| 50 | name = "reading_inventory" | ||
| 51 | required-features = ["gpu"] | ||
| 52 | |||
| 49 | [[example]] | 53 | [[example]] |
| 50 | name = "print_pdf" | 54 | name = "print_pdf" |
| 51 | required-features = ["pdf"] | 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,7 +544,38 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent |
| 544 | probe(&mut editor, engine, inventory, place); | 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 | let Ok(image) = std::fs::read(path) else { | 579 | let Ok(image) = std::fs::read(path) else { |
| 549 | return; | 580 | return; |
| 550 | }; | 581 | }; |
| ... | @@ -554,7 +585,15 @@ fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut In | ... | @@ -554,7 +585,15 @@ fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut In |
| 554 | .display() | 585 | .display() |
| 555 | .to_string(); | 586 | .to_string(); |
| 556 | let arena = Arena::default(); | 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 | Ok(section) => section, | 597 | Ok(section) => section, |
| 559 | Err(error) => { | 598 | Err(error) => { |
| 560 | inventory.add( | 599 | inventory.add( |
| ... | @@ -612,6 +651,10 @@ fn main() { | ... | @@ -612,6 +651,10 @@ fn main() { |
| 612 | let mut inventory = Inventory::default(); | 651 | let mut inventory = Inventory::default(); |
| 613 | let mut seen = BTreeSet::new(); | 652 | let mut seen = BTreeSet::new(); |
| 614 | let mut duplicates = 0; | 653 | let mut duplicates = 0; |
| 654 | let mut known = Vec::new(); | ||
| 655 | for root in &roots { | ||
| 656 | passwords(root, &mut known); | ||
| 657 | } | ||
| 615 | for root in &roots { | 658 | for root in &roots { |
| 616 | let mut paths = Vec::new(); | 659 | let mut paths = Vec::new(); |
| 617 | sections(root, &mut paths); | 660 | sections(root, &mut paths); |
| ... | @@ -625,7 +668,7 @@ fn main() { | ... | @@ -625,7 +668,7 @@ fn main() { |
| 625 | duplicates += 1; | 668 | duplicates += 1; |
| 626 | continue; | 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 | println!("# Unsupported content inventory\n"); | 674 | println!("# Unsupported content inventory\n"); |
crates/canvas/src/conflict.rs+14-30| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | //! Conflict pages and page versions as OneNote shows them. | 1 | //! Conflict pages and page versions as OneNote shows them. |
| 2 | 2 | ||
| 3 | use crate::document::descendants; | ||
| 3 | use onestore::{ | 4 | use onestore::{ |
| 4 | ExGuid, | 5 | ExGuid, |
| 5 | page::{Page, PageObject, PageParagraph, ParagraphContent}, | 6 | page::{Page, PageObject, PageParagraph, ParagraphContent}, |
| ... | @@ -21,9 +22,19 @@ pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page { | ... | @@ -21,9 +22,19 @@ pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page { |
| 21 | /// banded as OneNote shows it; all of it without one. | 22 | /// banded as OneNote shows it; all of it without one. |
| 22 | pub fn changes(version: Page, older: Option<&Page>) -> Page { | 23 | pub fn changes(version: Page, older: Option<&Page>) -> Page { |
| 23 | let texts = |page: &Page| { | 24 | let texts = |page: &Page| { |
| 24 | let mut texts = Vec::new(); | 25 | page.objects |
| 25 | each_text(page, &mut |id, text| texts.push((id, text.to_owned()))); | 26 | .iter() |
| 26 | texts | 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 | let before = older.map(texts).unwrap_or_default(); | 39 | let before = older.map(texts).unwrap_or_default(); |
| 29 | let changed: Vec<ExGuid> = texts(&version) | 40 | let changed: Vec<ExGuid> = texts(&version) |
| ... | @@ -34,33 +45,6 @@ pub fn changes(version: Page, older: Option<&Page>) -> Page { | ... | @@ -34,33 +45,6 @@ pub fn changes(version: Page, older: Option<&Page>) -> Page { |
| 34 | banded(version, &changed, CHANGED) | 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 | fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page { | 48 | fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page { |
| 65 | fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) { | 49 | fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) { |
| 66 | for paragraph in paragraphs { | 50 | for paragraph in paragraphs { |
crates/canvas/src/date.rs+1-1| ... | @@ -32,7 +32,7 @@ impl PageDate { | ... | @@ -32,7 +32,7 @@ impl PageDate { |
| 32 | &self.layout | 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 | let swapped = self | 36 | let swapped = self |
| 37 | .source | 37 | .source |
| 38 | .paragraphs | 38 | .paragraphs |
crates/canvas/src/document.rs+5-5| ... | @@ -26,11 +26,11 @@ pub struct TextDocument { | ... | @@ -26,11 +26,11 @@ pub struct TextDocument { |
| 26 | #[derive(Clone, Debug, PartialEq)] | 26 | #[derive(Clone, Debug, PartialEq)] |
| 27 | pub(crate) struct DocumentEdit { | 27 | pub(crate) struct DocumentEdit { |
| 28 | /// Cell identity, or None for the document root. | 28 | /// Cell identity, or None for the document root. |
| 29 | pub container: Option<ExGuid>, | 29 | pub(crate) container: Option<ExGuid>, |
| 30 | pub range: Range<usize>, | 30 | pub(crate) range: Range<usize>, |
| 31 | pub replacement: Vec<PageParagraph>, | 31 | pub(crate) replacement: Vec<PageParagraph>, |
| 32 | /// Width patches address surviving tables outside the replacement subtree. | 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 | pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> { | 36 | pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> { |
| ... | @@ -757,7 +757,7 @@ impl TextDocument { | ... | @@ -757,7 +757,7 @@ impl TextDocument { |
| 757 | Err(EditError::InvalidRange) | 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 | if range.start > range.end { | 761 | if range.start > range.end { |
| 762 | return Err(EditError::InvalidRange); | 762 | return Err(EditError::InvalidRange); |
| 763 | } | 763 | } |
crates/canvas/src/editor.rs+129-119| ... | @@ -100,10 +100,7 @@ pub struct Awaited { | ... | @@ -100,10 +100,7 @@ pub struct Awaited { |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | /// A new picture of encoded `bytes`, `size` points large. | 102 | /// A new picture of encoded `bytes`, `size` points large. |
| 103 | pub(crate) fn picture( | 103 | pub fn picture(bytes: Vec<u8>, size: [f32; 2]) -> Result<onestore::page::Image, EditError> { |
| 104 | bytes: Vec<u8>, | ||
| 105 | size: [f32; 2], | ||
| 106 | ) -> Result<onestore::page::Image, EditError> { | ||
| 107 | Ok(onestore::page::Image { | 104 | Ok(onestore::page::Image { |
| 108 | id: onestore::page::text::new_id()?, | 105 | id: onestore::page::text::new_id()?, |
| 109 | layout: onestore::document::Layout { | 106 | layout: onestore::document::Layout { |
| ... | @@ -187,6 +184,7 @@ struct PageHeader { | ... | @@ -187,6 +184,7 @@ struct PageHeader { |
| 187 | identity: Option<[u8; 16]>, | 184 | identity: Option<[u8; 16]>, |
| 188 | created: Option<u64>, | 185 | created: Option<u64>, |
| 189 | margin_origin: [f32; 2], | 186 | margin_origin: [f32; 2], |
| 187 | rtl: bool, | ||
| 190 | color: Option<u32>, | 188 | color: Option<u32>, |
| 191 | rule_lines: Option<onestore::page::RuleLines>, | 189 | rule_lines: Option<onestore::page::RuleLines>, |
| 192 | areas: Vec<page::TitleArea>, | 190 | areas: Vec<page::TitleArea>, |
| ... | @@ -378,7 +376,7 @@ impl TextOutline { | ... | @@ -378,7 +376,7 @@ impl TextOutline { |
| 378 | } | 376 | } |
| 379 | 377 | ||
| 380 | pub fn origin(&self) -> [f32; 2] { | 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 | pub fn wrap_width(&self) -> f32 { | 382 | pub fn wrap_width(&self) -> f32 { |
| ... | @@ -509,7 +507,7 @@ impl TextOutline { | ... | @@ -509,7 +507,7 @@ impl TextOutline { |
| 509 | } | 507 | } |
| 510 | 508 | ||
| 511 | /// All the outline's shown text. | 509 | /// All the outline's shown text. |
| 512 | pub fn whole(&self) -> Selection { | 510 | pub(crate) fn whole(&self) -> Selection { |
| 513 | let paragraph = self.source_index(self.shaped.paragraphs.len() - 1); | 511 | let paragraph = self.source_index(self.shaped.paragraphs.len() - 1); |
| 514 | let last = self.document.paragraph(paragraph).unwrap(); | 512 | let last = self.document.paragraph(paragraph).unwrap(); |
| 515 | [ | 513 | [ |
| ... | @@ -587,7 +585,11 @@ impl TextOutline { | ... | @@ -587,7 +585,11 @@ impl TextOutline { |
| 587 | 585 | ||
| 588 | /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each | 586 | /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each |
| 589 | /// line's left, right and baseline. | 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 | let paragraph = self.paragraph_layout(index)?; | 593 | let paragraph = self.paragraph_layout(index)?; |
| 592 | let selection = ParagraphSelection::new( | 594 | let selection = ParagraphSelection::new( |
| 593 | paragraph.cursor(range.start, Affinity::Downstream)?, | 595 | paragraph.cursor(range.start, Affinity::Downstream)?, |
| ... | @@ -935,39 +937,15 @@ impl CanvasEditor { | ... | @@ -935,39 +937,15 @@ impl CanvasEditor { |
| 935 | width: f32, | 937 | width: f32, |
| 936 | ) -> Result<Self, EditorError> { | 938 | ) -> Result<Self, EditorError> { |
| 937 | let outline = TextOutline::new(engine, document, width, [0.0; 2])?; | 939 | let outline = TextOutline::new(engine, document, width, [0.0; 2])?; |
| 938 | let (outlines, active) = if outline.is_empty() { | 940 | let empty = outline.is_empty(); |
| 939 | ( | 941 | let mut editor = Self::from_text_outlines(vec![outline], BTreeMap::new(), None)?; |
| 940 | Vec::new(), | 942 | if empty { |
| 941 | Focus::Caret { | 943 | editor.active = Focus::Caret { |
| 942 | outline: Box::new(outline), | 944 | outline: Box::new(editor.outlines.pop().unwrap()), |
| 943 | index: 0, | 945 | index: 0, |
| 944 | }, | 946 | }; |
| 945 | ) | 947 | } |
| 946 | } else { | 948 | Ok(editor) |
| 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 | }) | ||
| 971 | } | 949 | } |
| 972 | 950 | ||
| 973 | pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> { | 951 | pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> { |
| ... | @@ -1014,6 +992,7 @@ impl CanvasEditor { | ... | @@ -1014,6 +992,7 @@ impl CanvasEditor { |
| 1014 | identity: page.identity, | 992 | identity: page.identity, |
| 1015 | created: page.created, | 993 | created: page.created, |
| 1016 | margin_origin: page.margin_origin, | 994 | margin_origin: page.margin_origin, |
| 995 | rtl: page.rtl, | ||
| 1017 | color: page.color, | 996 | color: page.color, |
| 1018 | rule_lines: page.rule_lines, | 997 | rule_lines: page.rule_lines, |
| 1019 | areas, | 998 | areas, |
| ... | @@ -1317,6 +1296,7 @@ impl CanvasEditor { | ... | @@ -1317,6 +1296,7 @@ impl CanvasEditor { |
| 1317 | .map(PageDate::timestamp) | 1296 | .map(PageDate::timestamp) |
| 1318 | .or(self.header.created), | 1297 | .or(self.header.created), |
| 1319 | margin_origin: self.header.margin_origin, | 1298 | margin_origin: self.header.margin_origin, |
| 1299 | rtl: self.header.rtl, | ||
| 1320 | color: self.header.color, | 1300 | color: self.header.color, |
| 1321 | rule_lines: self.header.rule_lines, | 1301 | rule_lines: self.header.rule_lines, |
| 1322 | objects, | 1302 | objects, |
| ... | @@ -1516,7 +1496,8 @@ impl CanvasEditor { | ... | @@ -1516,7 +1496,8 @@ impl CanvasEditor { |
| 1516 | .collect() | 1496 | .collect() |
| 1517 | } | 1497 | } |
| 1518 | 1498 | ||
| 1519 | pub fn from_outlines( | 1499 | #[cfg(test)] |
| 1500 | pub(crate) fn from_outlines( | ||
| 1520 | engine: &mut TextEngine, | 1501 | engine: &mut TextEngine, |
| 1521 | outlines: Vec<Outline>, | 1502 | outlines: Vec<Outline>, |
| 1522 | definitions: BTreeMap<ExGuid, Definition>, | 1503 | definitions: BTreeMap<ExGuid, Definition>, |
| ... | @@ -1631,14 +1612,15 @@ impl CanvasEditor { | ... | @@ -1631,14 +1612,15 @@ impl CanvasEditor { |
| 1631 | return false; | 1612 | return false; |
| 1632 | } | 1613 | } |
| 1633 | self.finish_composition(); | 1614 | self.finish_composition(); |
| 1634 | let mut entries = Vec::new(); | 1615 | let depth = self.undo.len(); |
| 1635 | if paper { | 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 | if let Some(art) = art { | 1620 | if let Some(art) = art { |
| 1639 | for index in backgrounds.into_iter().rev() { | 1621 | for index in backgrounds.into_iter().rev() { |
| 1640 | let image = self.remove_picture(index); | 1622 | let image = self.remove_picture(index); |
| 1641 | entries.push(History::Picture { | 1623 | self.undo.push(History::Picture { |
| 1642 | index, | 1624 | index, |
| 1643 | image: Some(Box::new(image)), | 1625 | image: Some(Box::new(image)), |
| 1644 | }); | 1626 | }); |
| ... | @@ -1646,19 +1628,13 @@ impl CanvasEditor { | ... | @@ -1646,19 +1628,13 @@ impl CanvasEditor { |
| 1646 | // Art lies under everything else on the page. | 1628 | // Art lies under everything else on the page. |
| 1647 | for image in art.into_iter().rev() { | 1629 | for image in art.into_iter().rev() { |
| 1648 | self.add_picture(0, image); | 1630 | self.add_picture(0, image); |
| 1649 | entries.push(History::Picture { | 1631 | self.undo.push(History::Picture { |
| 1650 | index: 0, | 1632 | index: 0, |
| 1651 | image: None, | 1633 | image: None, |
| 1652 | }); | 1634 | }); |
| 1653 | } | 1635 | } |
| 1654 | } | 1636 | } |
| 1655 | self.undo.push(match entries.len() { | 1637 | self.group(depth, false); |
| 1656 | 1 => entries.pop().unwrap(), | ||
| 1657 | _ => History::Group { | ||
| 1658 | entries, | ||
| 1659 | page: false, | ||
| 1660 | }, | ||
| 1661 | }); | ||
| 1662 | self.redo.clear(); | 1638 | self.redo.clear(); |
| 1663 | true | 1639 | true |
| 1664 | } | 1640 | } |
| ... | @@ -1748,7 +1724,6 @@ impl CanvasEditor { | ... | @@ -1748,7 +1724,6 @@ impl CanvasEditor { |
| 1748 | } | 1724 | } |
| 1749 | } | 1725 | } |
| 1750 | 1726 | ||
| 1751 | /// Provisional input geometry outside the document's outline collection. | ||
| 1752 | /// The caret a run of typing left while typing there still joins the run: the word | 1727 | /// The caret a run of typing left while typing there still joins the run: the word |
| 1753 | /// before it is still being written. | 1728 | /// before it is still being written. |
| 1754 | pub fn typing(&self) -> Option<(ExGuid, TextPosition)> { | 1729 | pub fn typing(&self) -> Option<(ExGuid, TextPosition)> { |
| ... | @@ -1759,6 +1734,7 @@ impl CanvasEditor { | ... | @@ -1759,6 +1734,7 @@ impl CanvasEditor { |
| 1759 | .then_some((outline, at)) | 1734 | .then_some((outline, at)) |
| 1760 | } | 1735 | } |
| 1761 | 1736 | ||
| 1737 | /// Provisional input geometry outside the document's outline collection. | ||
| 1762 | pub fn caret_outline(&self) -> Option<&TextOutline> { | 1738 | pub fn caret_outline(&self) -> Option<&TextOutline> { |
| 1763 | match &self.active { | 1739 | match &self.active { |
| 1764 | Focus::Caret { outline, .. } => Some(outline), | 1740 | Focus::Caret { outline, .. } => Some(outline), |
| ... | @@ -2020,9 +1996,8 @@ impl CanvasEditor { | ... | @@ -2020,9 +1996,8 @@ impl CanvasEditor { |
| 2020 | return Some(([x0, y0], [x1 - x0, y1 - y0])); | 1996 | return Some(([x0, y0], [x1 - x0, y1 - y0])); |
| 2021 | } | 1997 | } |
| 2022 | if let Some(image) = self.image(id) { | 1998 | if let Some(image) = self.image(id) { |
| 2023 | let layout = &image.layout; | ||
| 2024 | return Some(( | 1999 | return Some(( |
| 2025 | [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], | 2000 | crate::origin(&image.layout), |
| 2026 | crate::outline::image_size(image)?, | 2001 | crate::outline::image_size(image)?, |
| 2027 | )); | 2002 | )); |
| 2028 | } | 2003 | } |
| ... | @@ -2034,7 +2009,7 @@ impl CanvasEditor { | ... | @@ -2034,7 +2009,7 @@ impl CanvasEditor { |
| 2034 | } | 2009 | } |
| 2035 | 2010 | ||
| 2036 | /// Whether the picture sits in an outline's flow, where only its size can change. | 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 | self.outline_picture(id).is_some() | 2013 | self.outline_picture(id).is_some() |
| 2039 | } | 2014 | } |
| 2040 | 2015 | ||
| ... | @@ -2052,7 +2027,7 @@ impl CanvasEditor { | ... | @@ -2052,7 +2027,7 @@ impl CanvasEditor { |
| 2052 | } | 2027 | } |
| 2053 | 2028 | ||
| 2054 | /// The address picture `id` links to, in an outline or on the page. | 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 | let image = match self.image(id) { | 2031 | let image = match self.image(id) { |
| 2057 | Some(image) => image, | 2032 | Some(image) => image, |
| 2058 | None => match &self.outline_picture(id)?.3.content { | 2033 | None => match &self.outline_picture(id)?.3.content { |
| ... | @@ -2064,7 +2039,7 @@ impl CanvasEditor { | ... | @@ -2064,7 +2039,7 @@ impl CanvasEditor { |
| 2064 | } | 2039 | } |
| 2065 | 2040 | ||
| 2066 | /// The file a paragraph holds or the page shows, which the host opens and saves. | 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 | if let Some((file, _)) = self.page_file(id) { | 2043 | if let Some((file, _)) = self.page_file(id) { |
| 2069 | return Some(file); | 2044 | return Some(file); |
| 2070 | } | 2045 | } |
| ... | @@ -2083,15 +2058,14 @@ impl CanvasEditor { | ... | @@ -2083,15 +2058,14 @@ impl CanvasEditor { |
| 2083 | } | 2058 | } |
| 2084 | 2059 | ||
| 2085 | /// Where a file draws with its name, in page coordinates. | 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 | if let Some((_, rect)) = self.page_file(id) { | 2062 | if let Some((_, rect)) = self.page_file(id) { |
| 2088 | return Some(rect); | 2063 | return Some(rect); |
| 2089 | } | 2064 | } |
| 2090 | let (outline, ..) = self.outline_picture(id)?; | 2065 | let (outline, ..) = self.outline_picture(id)?; |
| 2091 | let outline = self.outlines.iter().find(|item| item.id == outline)?; | 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(); | 2067 | let bounds = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); |
| 2093 | let [x, y] = outline.origin(); | 2068 | Some(crate::translated(bounds, outline.origin())) |
| 2094 | Some([x + x0, y + y0, x + x1, y + y1]) | ||
| 2095 | } | 2069 | } |
| 2096 | 2070 | ||
| 2097 | /// Replaces a picture paragraph through the outline's text history. | 2071 | /// Replaces a picture paragraph through the outline's text history. |
| ... | @@ -2231,7 +2205,7 @@ impl CanvasEditor { | ... | @@ -2231,7 +2205,7 @@ impl CanvasEditor { |
| 2231 | /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the | 2205 | /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the |
| 2232 | /// text outline before it in page order, or to the start of the one after. False when there | 2206 | /// text outline before it in page order, or to the start of the one after. False when there |
| 2233 | /// is none. | 2207 | /// is none. |
| 2234 | pub fn step_from_image( | 2208 | pub(crate) fn step_from_image( |
| 2235 | &mut self, | 2209 | &mut self, |
| 2236 | engine: &mut TextEngine, | 2210 | engine: &mut TextEngine, |
| 2237 | id: ExGuid, | 2211 | id: ExGuid, |
| ... | @@ -2394,15 +2368,7 @@ impl CanvasEditor { | ... | @@ -2394,15 +2368,7 @@ impl CanvasEditor { |
| 2394 | break; | 2368 | break; |
| 2395 | } | 2369 | } |
| 2396 | } | 2370 | } |
| 2397 | let entries = self.undo.split_off(depth); | 2371 | self.group(depth, false); |
| 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 | } | ||
| 2406 | result.map(|()| awaited) | 2372 | result.map(|()| awaited) |
| 2407 | } | 2373 | } |
| 2408 | 2374 | ||
| ... | @@ -2414,7 +2380,11 @@ impl CanvasEditor { | ... | @@ -2414,7 +2380,11 @@ impl CanvasEditor { |
| 2414 | at: Awaited, | 2380 | at: Awaited, |
| 2415 | image: onestore::page::Image, | 2381 | image: onestore::page::Image, |
| 2416 | ) -> Result<bool, EditorError> { | 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 | return Ok(false); | 2388 | return Ok(false); |
| 2419 | }; | 2389 | }; |
| 2420 | let Some((container, index, node)) = descendants(outline.document.nodes(), None) | 2390 | let Some((container, index, node)) = descendants(outline.document.nodes(), None) |
| ... | @@ -2605,6 +2575,12 @@ impl CanvasEditor { | ... | @@ -2605,6 +2575,12 @@ impl CanvasEditor { |
| 2605 | self.header.margin_origin | 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 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { | 2584 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { |
| 2609 | let outline = self.active_outline(); | 2585 | let outline = self.active_outline(); |
| 2610 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) | 2586 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) |
| ... | @@ -2760,7 +2736,7 @@ impl CanvasEditor { | ... | @@ -2760,7 +2736,7 @@ impl CanvasEditor { |
| 2760 | } | 2736 | } |
| 2761 | 2737 | ||
| 2762 | /// Selects outline `id` as an object, as a click on its handle does. | 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 | self.focus_outline(id)?; | 2740 | self.focus_outline(id)?; |
| 2765 | let whole = self.active_outline().whole(); | 2741 | let whole = self.active_outline().whole(); |
| 2766 | self.select(whole)?; | 2742 | self.select(whole)?; |
| ... | @@ -2793,20 +2769,38 @@ impl CanvasEditor { | ... | @@ -2793,20 +2769,38 @@ impl CanvasEditor { |
| 2793 | Ok(()) | 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 | fn grouped<T>( | 2797 | fn grouped<T>( |
| 2798 | &mut self, | 2798 | &mut self, |
| 2799 | edit: impl FnOnce(&mut Self) -> Result<T, EditorError>, | 2799 | edit: impl FnOnce(&mut Self) -> Result<T, EditorError>, |
| 2800 | ) -> Result<T, EditorError> { | 2800 | ) -> Result<T, EditorError> { |
| 2801 | let depth = self.undo.len(); | 2801 | let depth = self.undo.len(); |
| 2802 | let result = edit(self); | 2802 | let result = edit(self); |
| 2803 | let entries = self.undo.split_off(depth); | 2803 | self.group(depth, true); |
| 2804 | if !entries.is_empty() { | ||
| 2805 | self.undo.push(History::Group { | ||
| 2806 | entries, | ||
| 2807 | page: true, | ||
| 2808 | }); | ||
| 2809 | } | ||
| 2810 | result | 2804 | result |
| 2811 | } | 2805 | } |
| 2812 | 2806 | ||
| ... | @@ -2828,13 +2822,7 @@ impl CanvasEditor { | ... | @@ -2828,13 +2822,7 @@ impl CanvasEditor { |
| 2828 | } | 2822 | } |
| 2829 | let depth = self.undo.len(); | 2823 | let depth = self.undo.len(); |
| 2830 | let result = self.delete(engine, false).and_then(|_| edit(self, engine)); | 2824 | let result = self.delete(engine, false).and_then(|_| edit(self, engine)); |
| 2831 | let entries = self.undo.split_off(depth); | 2825 | self.group(depth, false); |
| 2832 | if !entries.is_empty() { | ||
| 2833 | self.undo.push(History::Group { | ||
| 2834 | entries, | ||
| 2835 | page: false, | ||
| 2836 | }); | ||
| 2837 | } | ||
| 2838 | result.map(Some) | 2826 | result.map(Some) |
| 2839 | } | 2827 | } |
| 2840 | 2828 | ||
| ... | @@ -2858,27 +2846,8 @@ impl CanvasEditor { | ... | @@ -2858,27 +2846,8 @@ impl CanvasEditor { |
| 2858 | }); | 2846 | }); |
| 2859 | let depth = self.undo.len() - usize::from(joins); | 2847 | let depth = self.undo.len() - usize::from(joins); |
| 2860 | let result = edit(self); | 2848 | let result = edit(self); |
| 2861 | let (mut run, mut page) = (Vec::new(), false); | 2849 | if !self.group(depth, false) { |
| 2862 | for entry in self.undo.split_off(depth) { | 2850 | return result; |
| 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 }), | ||
| 2882 | } | 2851 | } |
| 2883 | if result.is_ok() { | 2852 | if result.is_ok() { |
| 2884 | let caret = self.selection().positions[1]; | 2853 | let caret = self.selection().positions[1]; |
| ... | @@ -2957,7 +2926,7 @@ impl CanvasEditor { | ... | @@ -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 | let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; | 2930 | let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; |
| 2962 | if extend { | 2931 | if extend { |
| 2963 | selection.positions[0] = self.selection().positions[0]; | 2932 | selection.positions[0] = self.selection().positions[0]; |
| ... | @@ -3448,12 +3417,12 @@ impl CanvasEditor { | ... | @@ -3448,12 +3417,12 @@ impl CanvasEditor { |
| 3448 | Ok(()) | 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 | self.active_outline() | 3421 | self.active_outline() |
| 3453 | .range_rects(self.active_outline().selection) | 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 | match &self.composition { | 3426 | match &self.composition { |
| 3458 | Some(composition) => self.active_outline().range_rects(Selection { | 3427 | Some(composition) => self.active_outline().range_rects(Selection { |
| 3459 | positions: [composition.range.start, composition.range.end], | 3428 | positions: [composition.range.start, composition.range.end], |
| ... | @@ -3506,7 +3475,11 @@ impl CanvasEditor { | ... | @@ -3506,7 +3475,11 @@ impl CanvasEditor { |
| 3506 | 3475 | ||
| 3507 | /// Replaces the selection with `text` in the formatting of the text it replaces, as a | 3476 | /// Replaces the selection with `text` in the formatting of the text it replaces, as a |
| 3508 | /// spelling correction does. | 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 | let [anchor, focus] = self.selection().positions; | 3483 | let [anchor, focus] = self.selection().positions; |
| 3511 | let end = anchor.max(focus); | 3484 | let end = anchor.max(focus); |
| 3512 | let format = self | 3485 | let format = self |
| ... | @@ -3752,7 +3725,7 @@ impl CanvasEditor { | ... | @@ -3752,7 +3725,7 @@ impl CanvasEditor { |
| 3752 | 3725 | ||
| 3753 | /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the | 3726 | /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the |
| 3754 | /// sibling above or below with its children. Does nothing past the first or last sibling. | 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 | &mut self, | 3729 | &mut self, |
| 3757 | engine: &mut TextEngine, | 3730 | engine: &mut TextEngine, |
| 3758 | up: bool, | 3731 | up: bool, |
| ... | @@ -3994,7 +3967,7 @@ impl CanvasEditor { | ... | @@ -3994,7 +3967,7 @@ impl CanvasEditor { |
| 3994 | 3967 | ||
| 3995 | /// Takes up `parked`, the editor this page had when it was last left, history and all, | 3968 | /// Takes up `parked`, the editor this page had when it was last left, history and all, |
| 3996 | /// in place of this one: what storage changed since reaches it as `refresh` would. | 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 | &mut self, | 3971 | &mut self, |
| 3999 | mut parked: CanvasEditor, | 3972 | mut parked: CanvasEditor, |
| 4000 | engine: &mut TextEngine, | 3973 | engine: &mut TextEngine, |
| ... | @@ -4928,9 +4901,6 @@ impl CanvasEditor { | ... | @@ -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 | /// Whether `content` is picture or file `id`. | 4904 | /// Whether `content` is picture or file `id`. |
| 4935 | fn holds(content: &ParagraphContent, id: ExGuid) -> bool { | 4905 | fn holds(content: &ParagraphContent, id: ExGuid) -> bool { |
| 4936 | match content { | 4906 | match content { |
| ... | @@ -4940,6 +4910,9 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool { | ... | @@ -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 | fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { | 4916 | fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { |
| 4944 | let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { | 4917 | let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { |
| 4945 | let paragraph = new | 4918 | let paragraph = new |
| ... | @@ -6447,6 +6420,7 @@ mod tests { | ... | @@ -6447,6 +6420,7 @@ mod tests { |
| 6447 | identity: None, | 6420 | identity: None, |
| 6448 | created: None, | 6421 | created: None, |
| 6449 | margin_origin: [36.0, 14.4], | 6422 | margin_origin: [36.0, 14.4], |
| 6423 | rtl: false, | ||
| 6450 | color: None, | 6424 | color: None, |
| 6451 | rule_lines: None, | 6425 | rule_lines: None, |
| 6452 | definitions: BTreeMap::new(), | 6426 | definitions: BTreeMap::new(), |
| ... | @@ -6558,6 +6532,7 @@ mod tests { | ... | @@ -6558,6 +6532,7 @@ mod tests { |
| 6558 | identity: None, | 6532 | identity: None, |
| 6559 | created: None, | 6533 | created: None, |
| 6560 | margin_origin: [36.0, 14.4], | 6534 | margin_origin: [36.0, 14.4], |
| 6535 | rtl: false, | ||
| 6561 | color: None, | 6536 | color: None, |
| 6562 | rule_lines: None, | 6537 | rule_lines: None, |
| 6563 | definitions: BTreeMap::new(), | 6538 | definitions: BTreeMap::new(), |
| ... | @@ -6656,6 +6631,7 @@ mod tests { | ... | @@ -6656,6 +6631,7 @@ mod tests { |
| 6656 | identity: None, | 6631 | identity: None, |
| 6657 | created: None, | 6632 | created: None, |
| 6658 | margin_origin: [36.0, 14.4], | 6633 | margin_origin: [36.0, 14.4], |
| 6634 | rtl: false, | ||
| 6659 | color: None, | 6635 | color: None, |
| 6660 | rule_lines: None, | 6636 | rule_lines: None, |
| 6661 | definitions: BTreeMap::new(), | 6637 | definitions: BTreeMap::new(), |
| ... | @@ -6720,6 +6696,7 @@ mod tests { | ... | @@ -6720,6 +6696,7 @@ mod tests { |
| 6720 | identity: None, | 6696 | identity: None, |
| 6721 | created: None, | 6697 | created: None, |
| 6722 | margin_origin: [36.0, 14.4], | 6698 | margin_origin: [36.0, 14.4], |
| 6699 | rtl: false, | ||
| 6723 | color: None, | 6700 | color: None, |
| 6724 | rule_lines: None, | 6701 | rule_lines: None, |
| 6725 | definitions: BTreeMap::new(), | 6702 | definitions: BTreeMap::new(), |
| ... | @@ -6840,6 +6817,7 @@ mod tests { | ... | @@ -6840,6 +6817,7 @@ mod tests { |
| 6840 | identity: None, | 6817 | identity: None, |
| 6841 | created: None, | 6818 | created: None, |
| 6842 | margin_origin: [36.0, 14.4], | 6819 | margin_origin: [36.0, 14.4], |
| 6820 | rtl: false, | ||
| 6843 | color: None, | 6821 | color: None, |
| 6844 | rule_lines: None, | 6822 | rule_lines: None, |
| 6845 | definitions: BTreeMap::new(), | 6823 | definitions: BTreeMap::new(), |
| ... | @@ -6959,6 +6937,7 @@ mod tests { | ... | @@ -6959,6 +6937,7 @@ mod tests { |
| 6959 | identity: None, | 6937 | identity: None, |
| 6960 | created: None, | 6938 | created: None, |
| 6961 | margin_origin: [36.0, 14.4], | 6939 | margin_origin: [36.0, 14.4], |
| 6940 | rtl: false, | ||
| 6962 | color: None, | 6941 | color: None, |
| 6963 | rule_lines: None, | 6942 | rule_lines: None, |
| 6964 | definitions: BTreeMap::new(), | 6943 | definitions: BTreeMap::new(), |
| ... | @@ -7060,6 +7039,7 @@ mod tests { | ... | @@ -7060,6 +7039,7 @@ mod tests { |
| 7060 | identity: None, | 7039 | identity: None, |
| 7061 | created: None, | 7040 | created: None, |
| 7062 | margin_origin: [36.0, 14.4], | 7041 | margin_origin: [36.0, 14.4], |
| 7042 | rtl: false, | ||
| 7063 | color: None, | 7043 | color: None, |
| 7064 | rule_lines: None, | 7044 | rule_lines: None, |
| 7065 | definitions: BTreeMap::new(), | 7045 | definitions: BTreeMap::new(), |
| ... | @@ -10623,4 +10603,34 @@ mod tests { | ... | @@ -10623,4 +10603,34 @@ mod tests { |
| 10623 | [[(0, 0), (0, 5)]; 6] | 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,7 +186,7 @@ impl CanvasEditor { |
| 186 | } | 186 | } |
| 187 | 187 | ||
| 188 | /// Whether the caret is in an equation, which Linear and Professional act on. | 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 | self.caret_zone().is_some() | 190 | self.caret_zone().is_some() |
| 191 | } | 191 | } |
| 192 | 192 | ||
| ... | @@ -270,15 +270,7 @@ impl CanvasEditor { | ... | @@ -270,15 +270,7 @@ impl CanvasEditor { |
| 270 | } | 270 | } |
| 271 | Ok(()) | 271 | Ok(()) |
| 272 | })(); | 272 | })(); |
| 273 | let entries = self.undo.split_off(depth); | 273 | self.group(depth, false); |
| 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 | } | ||
| 282 | result | 274 | result |
| 283 | } | 275 | } |
| 284 | 276 |
crates/canvas/src/editor/format.rs+63-89| ... | @@ -208,8 +208,8 @@ impl DefaultFont { | ... | @@ -208,8 +208,8 @@ impl DefaultFont { |
| 208 | subscript: Some(false), | 208 | subscript: Some(false), |
| 209 | font: Some(self.face.clone()), | 209 | font: Some(self.face.clone()), |
| 210 | font_size: Some(self.size), | 210 | font_size: Some(self.size), |
| 211 | color: Some(self.color.unwrap_or(0xff00_0000)), | 211 | color: Some(self.color.unwrap_or(AUTOMATIC)), |
| 212 | highlight: Some(0xff00_0000), | 212 | highlight: Some(AUTOMATIC), |
| 213 | space_before: Some(0.0), | 213 | space_before: Some(0.0), |
| 214 | space_after: Some(0.0), | 214 | space_after: Some(0.0), |
| 215 | line_spacing: Some(0.0), | 215 | line_spacing: Some(0.0), |
| ... | @@ -293,7 +293,7 @@ impl NoteTag { | ... | @@ -293,7 +293,7 @@ impl NoteTag { |
| 293 | } | 293 | } |
| 294 | 294 | ||
| 295 | /// The definition OneNote stores for the tag at place `action_type` in the list. | 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 | Definition { | 297 | Definition { |
| 298 | kind: Kind::TagDefinition { | 298 | kind: Kind::TagDefinition { |
| 299 | label: Some(self.label.clone()), | 299 | label: Some(self.label.clone()), |
| ... | @@ -453,18 +453,18 @@ fn covered( | ... | @@ -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 | for node in nodes { | 458 | for node in nodes { |
| 458 | match &mut node.content { | 459 | if node.text().is_some() { |
| 459 | ParagraphContent::Text(_) => change(node), | 460 | leaves.push(node); |
| 460 | ParagraphContent::Table(table) => { | 461 | } else if let ParagraphContent::Table(table) = &mut node.content { |
| 461 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { | 462 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { |
| 462 | leaves_mut(&mut cell.paragraphs, change); | 463 | leaves.extend(leaves_mut(&mut cell.paragraphs)); |
| 463 | } | ||
| 464 | } | 464 | } |
| 465 | _ => {} | ||
| 466 | } | 465 | } |
| 467 | } | 466 | } |
| 467 | leaves | ||
| 468 | } | 468 | } |
| 469 | 469 | ||
| 470 | /// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty. | 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,6 +753,7 @@ impl CanvasEditor { |
| 753 | | Formatting::Tag(..) | 753 | | Formatting::Tag(..) |
| 754 | | Formatting::RemoveTags | 754 | | Formatting::RemoveTags |
| 755 | | Formatting::Check | 755 | | Formatting::Check |
| 756 | | Formatting::Style(_) | ||
| 756 | if title => | 757 | if title => |
| 757 | { | 758 | { |
| 758 | return Ok(()); | 759 | return Ok(()); |
| ... | @@ -763,7 +764,7 @@ impl CanvasEditor { | ... | @@ -763,7 +764,7 @@ impl CanvasEditor { |
| 763 | } | 764 | } |
| 764 | Formatting::Align(alignment) => { | 765 | Formatting::Align(alignment) => { |
| 765 | let bases = bases(&replacement)?; | 766 | let bases = bases(&replacement)?; |
| 766 | leaves_mut(&mut replacement, &mut |node| { | 767 | for node in leaves_mut(&mut replacement) { |
| 767 | if ranges.remove(&node.id).is_some() { | 768 | if ranges.remove(&node.id).is_some() { |
| 768 | let value = match alignment { | 769 | let value = match alignment { |
| 769 | Alignment::Left => bases[&node.id].alignment.map(|_| 0), | 770 | Alignment::Left => bases[&node.id].alignment.map(|_| 0), |
| ... | @@ -775,7 +776,7 @@ impl CanvasEditor { | ... | @@ -775,7 +776,7 @@ impl CanvasEditor { |
| 775 | format.alignment = value; | 776 | format.alignment = value; |
| 776 | }); | 777 | }); |
| 777 | } | 778 | } |
| 778 | }); | 779 | } |
| 779 | } | 780 | } |
| 780 | Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => { | 781 | Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => { |
| 781 | let style = match command { | 782 | let style = match command { |
| ... | @@ -821,7 +822,7 @@ impl CanvasEditor { | ... | @@ -821,7 +822,7 @@ impl CanvasEditor { |
| 821 | self.definitions.insert(id, definition); | 822 | self.definitions.insert(id, definition); |
| 822 | lists.insert(node, id); | 823 | lists.insert(node, id); |
| 823 | } | 824 | } |
| 824 | leaves_mut(&mut replacement, &mut |node| { | 825 | for node in leaves_mut(&mut replacement) { |
| 825 | if ranges.remove(&node.id).is_some() { | 826 | if ranges.remove(&node.id).is_some() { |
| 826 | if remove { | 827 | if remove { |
| 827 | node.lists.clear(); | 828 | node.lists.clear(); |
| ... | @@ -829,50 +830,40 @@ impl CanvasEditor { | ... | @@ -829,50 +830,40 @@ impl CanvasEditor { |
| 829 | node.lists = vec![*id]; | 830 | node.lists = vec![*id]; |
| 830 | } | 831 | } |
| 831 | } | 832 | } |
| 832 | }); | 833 | } |
| 833 | } | 834 | } |
| 834 | Formatting::Tag(tag, action_type) => { | 835 | Formatting::Tag(tag, action_type) => { |
| 835 | let definition = tag.definition(*action_type); | 836 | let definition = tag.definition(*action_type); |
| 836 | let existing = self | 837 | let id = self.define_tag(&definition)?; |
| 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 | }; | ||
| 848 | let has = |node: &PageParagraph| { | 838 | let has = |node: &PageParagraph| { |
| 849 | tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) | 839 | tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) |
| 850 | }; | 840 | }; |
| 851 | let remove = covered(&replacement, ends).all(|(node, _)| has(node)); | 841 | let remove = covered(&replacement, ends).all(|(node, _)| has(node)); |
| 852 | let created = time32(); | 842 | let created = time32(); |
| 853 | leaves_mut(&mut replacement, &mut |node| { | 843 | for node in leaves_mut(&mut replacement) { |
| 854 | if ranges.remove(&node.id).is_none() || !remove && has(node) { | 844 | if ranges.remove(&node.id).is_none() || !remove && has(node) { |
| 855 | return; | 845 | continue; |
| 856 | } | 846 | } |
| 857 | let ParagraphContent::Text(text) = &mut node.content else { | 847 | let ParagraphContent::Text(text) = &mut node.content else { |
| 858 | unreachable!() | 848 | unreachable!() |
| 859 | }; | 849 | }; |
| 860 | let added = (!remove).then_some((id, tag.shape, created)); | 850 | let added = (!remove).then_some((id, tag.shape, created)); |
| 861 | self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); | 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| { | 854 | Formatting::RemoveTags => { |
| 865 | if ranges.remove(&node.id).is_some() { | 855 | for node in leaves_mut(&mut replacement) { |
| 866 | node.tags.clear(); | 856 | if ranges.remove(&node.id).is_some() { |
| 867 | node.text_mut().unwrap().tags.clear(); | 857 | node.tags.clear(); |
| 858 | node.text_mut().unwrap().tags.clear(); | ||
| 859 | } | ||
| 868 | } | 860 | } |
| 869 | }), | 861 | } |
| 870 | Formatting::Check => self.check(&mut replacement, ranges, ends), | 862 | Formatting::Check => self.check(&mut replacement, &ranges), |
| 871 | Formatting::Style(_) if title => return Ok(()), | ||
| 872 | Formatting::Style(definition) => { | 863 | Formatting::Style(definition) => { |
| 873 | let olds = bases(&replacement)?; | 864 | let olds = bases(&replacement)?; |
| 874 | let style = self.define_style(definition)?; | 865 | let style = self.define_style(definition)?; |
| 875 | leaves_mut(&mut replacement, &mut |node| { | 866 | for node in leaves_mut(&mut replacement) { |
| 876 | if ranges.remove(&node.id).is_some() { | 867 | if ranges.remove(&node.id).is_some() { |
| 877 | node.style = Some(style); | 868 | node.style = Some(style); |
| 878 | let old = &olds[&node.id]; | 869 | let old = &olds[&node.id]; |
| ... | @@ -899,7 +890,7 @@ impl CanvasEditor { | ... | @@ -899,7 +890,7 @@ impl CanvasEditor { |
| 899 | }; | 890 | }; |
| 900 | }); | 891 | }); |
| 901 | } | 892 | } |
| 902 | }); | 893 | } |
| 903 | } | 894 | } |
| 904 | Formatting::Toggle(_) | 895 | Formatting::Toggle(_) |
| 905 | | Formatting::Font(_) | 896 | | Formatting::Font(_) |
| ... | @@ -971,7 +962,7 @@ impl CanvasEditor { | ... | @@ -971,7 +962,7 @@ impl CanvasEditor { |
| 971 | self.pending = Some((id, caret, format)); | 962 | self.pending = Some((id, caret, format)); |
| 972 | return Ok(()); | 963 | return Ok(()); |
| 973 | } | 964 | } |
| 974 | leaves_mut(&mut replacement, &mut |node| { | 965 | for node in leaves_mut(&mut replacement) { |
| 975 | if let Some(range) = ranges.remove(&node.id) { | 966 | if let Some(range) = ranges.remove(&node.id) { |
| 976 | let base = &bases[&node.id]; | 967 | let base = &bases[&node.id]; |
| 977 | let text = &mut node.text_mut().unwrap().text; | 968 | let text = &mut node.text_mut().unwrap().text; |
| ... | @@ -984,7 +975,7 @@ impl CanvasEditor { | ... | @@ -984,7 +975,7 @@ impl CanvasEditor { |
| 984 | }); | 975 | }); |
| 985 | } | 976 | } |
| 986 | } | 977 | } |
| 987 | }); | 978 | } |
| 988 | } | 979 | } |
| 989 | } | 980 | } |
| 990 | if self.active_outline().document.container(container)?[range.clone()] == replacement[..] { | 981 | if self.active_outline().document.container(container)?[range.clone()] == replacement[..] { |
| ... | @@ -1018,6 +1009,20 @@ impl CanvasEditor { | ... | @@ -1018,6 +1009,20 @@ impl CanvasEditor { |
| 1018 | Ok(id) | 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 | /// A paragraph style named `name`: the page's in its theme's formatting, else the page's | 1026 | /// A paragraph style named `name`: the page's in its theme's formatting, else the page's |
| 1022 | /// first of that name, else the theme's made on the page; Normal falls back to the | 1027 | /// first of that name, else the theme's made on the page; Normal falls back to the |
| 1023 | /// Default font's. None where nothing names it. | 1028 | /// Default font's. None where nothing names it. |
| ... | @@ -1100,7 +1105,7 @@ impl CanvasEditor { | ... | @@ -1100,7 +1105,7 @@ impl CanvasEditor { |
| 1100 | let ranges = covered(&replacement, ends) | 1105 | let ranges = covered(&replacement, ends) |
| 1101 | .map(|(node, range)| (node.id, range)) | 1106 | .map(|(node, range)| (node.id, range)) |
| 1102 | .collect(); | 1107 | .collect(); |
| 1103 | self.check(&mut replacement, ranges, ends); | 1108 | self.check(&mut replacement, &ranges); |
| 1104 | let selection = outline.selection; | 1109 | let selection = outline.selection; |
| 1105 | self.commit( | 1110 | self.commit( |
| 1106 | engine, | 1111 | engine, |
| ... | @@ -1176,19 +1181,7 @@ impl CanvasEditor { | ... | @@ -1176,19 +1181,7 @@ impl CanvasEditor { |
| 1176 | match command { | 1181 | match command { |
| 1177 | Formatting::Tag(tag, action_type) => { | 1182 | Formatting::Tag(tag, action_type) => { |
| 1178 | let definition = tag.definition(*action_type); | 1183 | let definition = tag.definition(*action_type); |
| 1179 | let existing = self | 1184 | let defined = self.define_tag(&definition)?; |
| 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 | }; | ||
| 1192 | let has = tags | 1185 | let has = tags |
| 1193 | .iter() | 1186 | .iter() |
| 1194 | .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); | 1187 | .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); |
| ... | @@ -1284,7 +1277,8 @@ impl CanvasEditor { | ... | @@ -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 | fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { | 1282 | fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { |
| 1289 | let checkable = |tag: &Tag| { | 1283 | let checkable = |tag: &Tag| { |
| 1290 | matches!( | 1284 | matches!( |
| ... | @@ -1307,39 +1301,19 @@ impl CanvasEditor { | ... | @@ -1307,39 +1301,19 @@ impl CanvasEditor { |
| 1307 | } | 1301 | } |
| 1308 | 1302 | ||
| 1309 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are | 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. | 1304 | /// checked. |
| 1311 | fn check( | 1305 | fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap<ExGuid, Range<usize>>) { |
| 1312 | &self, | 1306 | let tags = leaves_mut(replacement) |
| 1313 | replacement: &mut [PageParagraph], | 1307 | .into_iter() |
| 1314 | mut covered: BTreeMap<ExGuid, Range<usize>>, | 1308 | .filter(|node| covered.contains_key(&node.id)) |
| 1315 | ends: Ends, | 1309 | .flat_map(|node| { |
| 1316 | ) { | 1310 | let ParagraphContent::Text(text) = &mut node.content else { |
| 1317 | let checkable = |tag: &Tag| { | 1311 | unreachable!() |
| 1318 | matches!( | 1312 | }; |
| 1319 | self.tag_kind(tag), | 1313 | node.tags.iter_mut().chain(&mut text.tags) |
| 1320 | Some(Kind::TagDefinition { shape: Some(shape), .. }) | 1314 | }) |
| 1321 | if crate::outline::checkable(*shape) | 1315 | .collect(); |
| 1322 | ) | 1316 | self.toggle_checks(tags); |
| 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 | }); | ||
| 1343 | } | 1317 | } |
| 1344 | } | 1318 | } |
| 1345 | 1319 |
crates/canvas/src/editor/ink.rs+30-48| ... | @@ -35,7 +35,7 @@ impl Pen { | ... | @@ -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 | Self { | 39 | Self { |
| 40 | width: 70.0, | 40 | width: 70.0, |
| 41 | color: Some(color), | 41 | color: Some(color), |
| ... | @@ -130,25 +130,8 @@ impl CanvasEditor { | ... | @@ -130,25 +130,8 @@ impl CanvasEditor { |
| 130 | let page::Content::Ink(ink) = &self.objects[index] else { | 130 | let page::Content::Ink(ink) = &self.objects[index] else { |
| 131 | continue; | 131 | continue; |
| 132 | }; | 132 | }; |
| 133 | let offset = page::ink_offset(ink); | 133 | let offset = crate::origin(&ink.layout); |
| 134 | let touched = |stroke: &InkStroke| { | 134 | let touched = |stroke: &InkStroke| stroke_near(stroke, offset, [from, to], reach); |
| 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 | }; | ||
| 152 | let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink | 135 | let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink |
| 153 | .strokes | 136 | .strokes |
| 154 | .iter() | 137 | .iter() |
| ... | @@ -216,13 +199,13 @@ impl CanvasEditor { | ... | @@ -216,13 +199,13 @@ impl CanvasEditor { |
| 216 | 199 | ||
| 217 | /// The page's drawings, on top first, with most of their points inside `lasso`, a | 200 | /// The page's drawings, on top first, with most of their points inside `lasso`, a |
| 218 | /// closed path in page points. | 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 | self.objects | 203 | self.objects |
| 221 | .iter() | 204 | .iter() |
| 222 | .rev() | 205 | .rev() |
| 223 | .filter_map(|object| match object { | 206 | .filter_map(|object| match object { |
| 224 | page::Content::Ink(ink) => { | 207 | page::Content::Ink(ink) => { |
| 225 | let offset = page::ink_offset(ink); | 208 | let offset = crate::origin(&ink.layout); |
| 226 | let points = points(ink); | 209 | let points = points(ink); |
| 227 | let inside = points | 210 | let inside = points |
| 228 | .iter() | 211 | .iter() |
| ... | @@ -236,28 +219,11 @@ impl CanvasEditor { | ... | @@ -236,28 +219,11 @@ impl CanvasEditor { |
| 236 | } | 219 | } |
| 237 | 220 | ||
| 238 | /// The drawing on top whose strokes pass within `reach` of `point`. | 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 | self.objects.iter().rev().find_map(|object| match object { | 223 | self.objects.iter().rev().find_map(|object| match object { |
| 241 | page::Content::Ink(ink) => { | 224 | page::Content::Ink(ink) => { |
| 242 | let offset = page::ink_offset(ink); | 225 | let offset = crate::origin(&ink.layout); |
| 243 | let near = |stroke: &InkStroke| { | 226 | let near = |stroke: &InkStroke| stroke_near(stroke, offset, [point, point], reach); |
| 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 | }; | ||
| 261 | (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near))) | 227 | (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near))) |
| 262 | .then_some(ink.id) | 228 | .then_some(ink.id) |
| 263 | } | 229 | } |
| ... | @@ -271,11 +237,8 @@ impl CanvasEditor { | ... | @@ -271,11 +237,8 @@ impl CanvasEditor { |
| 271 | self.objects | 237 | self.objects |
| 272 | .iter() | 238 | .iter() |
| 273 | .filter_map(|object| match object { | 239 | .filter_map(|object| match object { |
| 274 | page::Content::Ink(ink) if ids.contains(&ink.id) => { | 240 | page::Content::Ink(ink) if ids.contains(&ink.id) => page::ink_bounds(ink) |
| 275 | let [dx, dy] = self.ink_drag_offset(ink.id); | 241 | .map(|bounds| crate::translated(bounds, 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 | } | ||
| 279 | _ => None, | 242 | _ => None, |
| 280 | }) | 243 | }) |
| 281 | .reduce(|a, b| { | 244 | .reduce(|a, b| { |
| ... | @@ -290,7 +253,7 @@ impl CanvasEditor { | ... | @@ -290,7 +253,7 @@ impl CanvasEditor { |
| 290 | 253 | ||
| 291 | /// Shows drawings dragged `delta` from where they lie, without storing it, until the | 254 | /// Shows drawings dragged `delta` from where they lie, without storing it, until the |
| 292 | /// drag ends with `move_ink` or `None`. | 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 | self.ink_drag = drag; | 257 | self.ink_drag = drag; |
| 295 | } | 258 | } |
| 296 | 259 | ||
| ... | @@ -427,6 +390,25 @@ impl CanvasEditor { | ... | @@ -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 | fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool { | 412 | fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool { |
| 431 | ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched)) | 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,18 +3,18 @@ |
| 3 | //! typed and ended by a space or Enter becomes a link of its own text, typing after a label | 3 | //! typed and ended by a space or Enter becomes a link of its own text, typing after a label |
| 4 | //! is plain text, and Remove Link leaves the label as plain text. | 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 | use super::*; | 7 | use super::*; |
| 8 | 8 | ||
| 9 | /// A link in a paragraph, in UTF-16 offsets of its stored text. | 9 | /// A link in a paragraph, in UTF-16 offsets of its stored text. |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] | 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | pub struct Link { | 11 | pub struct Link { |
| 12 | pub paragraph: usize, | 12 | pub(crate) paragraph: usize, |
| 13 | /// The hidden field code naming the address, before the label; none where the label is | 13 | /// The hidden field code naming the address, before the label; none where the label is |
| 14 | /// its own address. | 14 | /// its own address. |
| 15 | pub code: Option<Range<u32>>, | 15 | pub(crate) code: Option<Range<u32>>, |
| 16 | pub label: Range<u32>, | 16 | pub label: Range<u32>, |
| 17 | pub target: String, | 17 | pub(crate) target: String, |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | impl Link { | 20 | impl Link { |
| ... | @@ -193,7 +193,7 @@ impl CanvasEditor { | ... | @@ -193,7 +193,7 @@ impl CanvasEditor { |
| 193 | 193 | ||
| 194 | /// The address of the link drawn under document point `point` of outline `id`, which a | 194 | /// The address of the link drawn under document point `point` of outline `id`, which a |
| 195 | /// click opens as OneNote's does. | 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 | let outline = self.visible_outlines().find(|outline| outline.id == id)?; | 197 | let outline = self.visible_outlines().find(|outline| outline.id == id)?; |
| 198 | let index = outline.paragraph_at(x, y).ok()?; | 198 | let index = outline.paragraph_at(x, y).ok()?; |
| 199 | let paragraph = &outline.shaped.paragraphs[index]; | 199 | let paragraph = &outline.shaped.paragraphs[index]; |
| ... | @@ -414,7 +414,7 @@ impl CanvasEditor { | ... | @@ -414,7 +414,7 @@ impl CanvasEditor { |
| 414 | } | 414 | } |
| 415 | 415 | ||
| 416 | /// Select Link: the label of the link at the caret. | 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 | let [anchor, focus] = self.active_outline().selection.positions; | 418 | let [anchor, focus] = self.active_outline().selection.positions; |
| 419 | let Some(link) = self.link_at(anchor.min(focus)) else { | 419 | let Some(link) = self.link_at(anchor.min(focus)) else { |
| 420 | return Ok(false); | 420 | return Ok(false); |
| ... | @@ -491,25 +491,20 @@ impl CanvasEditor { | ... | @@ -491,25 +491,20 @@ impl CanvasEditor { |
| 491 | text: Paragraph, | 491 | text: Paragraph, |
| 492 | selection: Selection, | 492 | selection: Selection, |
| 493 | ) -> Result<(), EditorError> { | 493 | ) -> Result<(), EditorError> { |
| 494 | let document = &self.active_outline().document; | 494 | let (container, index, node) = self |
| 495 | let at = TextPosition { | 495 | .active_outline() |
| 496 | paragraph, | 496 | .document |
| 497 | offset: 0, | 497 | .leaf(paragraph) |
| 498 | }; | 498 | .ok_or(EditError::InvalidRange)?; |
| 499 | let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?; | 499 | let mut node = node.clone(); |
| 500 | let mut replacement = document.container(container)?[range.clone()].to_vec(); | 500 | node.text_mut().unwrap().text = text; |
| 501 | leaves_mut(&mut replacement, &mut |node| { | ||
| 502 | if node.id == id { | ||
| 503 | node.text_mut().unwrap().text = text.clone(); | ||
| 504 | } | ||
| 505 | }); | ||
| 506 | self.commit( | 501 | self.commit( |
| 507 | engine, | 502 | engine, |
| 508 | DocumentEdit { | 503 | DocumentEdit { |
| 509 | columns: BTreeMap::new(), | 504 | columns: BTreeMap::new(), |
| 510 | container, | 505 | container, |
| 511 | range, | 506 | range: index..index + 1, |
| 512 | replacement, | 507 | replacement: vec![node], |
| 513 | }, | 508 | }, |
| 514 | selection, | 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,7 +285,6 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { |
| 285 | Ok(ops) | 285 | Ok(ops) |
| 286 | } | 286 | } |
| 287 | 287 | ||
| 288 | /// A page picture's stored position, size and description. | ||
| 289 | /// The op giving `target` `tags`, with the definitions they name. | 288 | /// The op giving `target` `tags`, with the definitions they name. |
| 290 | pub(super) fn tags( | 289 | pub(super) fn tags( |
| 291 | target: ExGuid, | 290 | target: ExGuid, |
| ... | @@ -306,6 +305,7 @@ pub(super) fn tags( | ... | @@ -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 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { | 309 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { |
| 310 | vec![PageOp::Picture { | 310 | vec![PageOp::Picture { |
| 311 | picture: image.id, | 311 | picture: image.id, |
| ... | @@ -404,25 +404,17 @@ impl CanvasEditor { | ... | @@ -404,25 +404,17 @@ impl CanvasEditor { |
| 404 | return layout_ops(outline.id, &old, &moved(&outline.layout)); | 404 | return layout_ops(outline.id, &old, &moved(&outline.layout)); |
| 405 | } | 405 | } |
| 406 | for object in &self.objects { | 406 | for object in &self.objects { |
| 407 | match object { | 407 | if let Some(image) = object.picture().filter(|image| image.id == placement.id) { |
| 408 | page::Content::Image(image) if image.id == placement.id => return picture(image), | 408 | return picture(image); |
| 409 | page::Content::Ink(ink) if ink.id == placement.id => { | 409 | } |
| 410 | return layout_ops(ink.id, &old, &moved(&ink.layout)); | 410 | match (object, object.layout()) { |
| 411 | } | 411 | (page::Content::ReadOnly(object), Some((id, _))) |
| 412 | page::Content::File { source, .. } if source.id == placement.id => { | 412 | if id == placement.id && !matches!(object.source, PageObject::Outline(_)) => |
| 413 | return layout_ops(source.id, &old, &moved(&source.layout)); | 413 | { |
| 414 | } | 414 | return Err(refused("Unsupported objects cannot be moved")); |
| 415 | page::Content::Outline { source, .. } if source.id == placement.id => { | ||
| 416 | return layout_ops(source.id, &old, &moved(&source.layout)); | ||
| 417 | } | 415 | } |
| 418 | page::Content::ReadOnly(object) if object.source.id() == placement.id => { | 416 | (_, Some((id, layout))) if id == placement.id => { |
| 419 | return match &object.source { | 417 | return layout_ops(id, &old, &moved(layout)); |
| 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 | }; | ||
| 426 | } | 418 | } |
| 427 | _ => {} | 419 | _ => {} |
| 428 | } | 420 | } |
| ... | @@ -467,6 +459,7 @@ impl CanvasEditor { | ... | @@ -467,6 +459,7 @@ impl CanvasEditor { |
| 467 | identity: None, | 459 | identity: None, |
| 468 | created: None, | 460 | created: None, |
| 469 | margin_origin: [0.0; 2], | 461 | margin_origin: [0.0; 2], |
| 462 | rtl: false, | ||
| 470 | color: None, | 463 | color: None, |
| 471 | rule_lines: None, | 464 | rule_lines: None, |
| 472 | objects, | 465 | objects, |
crates/canvas/src/editor/page.rs+25-40| ... | @@ -10,19 +10,19 @@ use std::collections::BTreeMap; | ... | @@ -10,19 +10,19 @@ use std::collections::BTreeMap; |
| 10 | 10 | ||
| 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. | 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. |
| 12 | pub(crate) struct TitleArea { | 12 | pub(crate) struct TitleArea { |
| 13 | pub id: onestore::ExGuid, | 13 | pub(crate) id: onestore::ExGuid, |
| 14 | pub date: Option<onestore::ExGuid>, | 14 | pub(crate) date: Option<onestore::ExGuid>, |
| 15 | pub layout: onestore::document::Layout, | 15 | pub(crate) layout: onestore::document::Layout, |
| 16 | pub origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>, | 16 | pub(crate) origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>, |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | pub(crate) struct Import { | 19 | pub(crate) struct Import { |
| 20 | pub objects: Vec<Content>, | 20 | pub(crate) objects: Vec<Content>, |
| 21 | pub outlines: Vec<TextOutline>, | 21 | pub(crate) outlines: Vec<TextOutline>, |
| 22 | pub date: Option<PageDate>, | 22 | pub(crate) date: Option<PageDate>, |
| 23 | pub areas: Vec<TitleArea>, | 23 | pub(crate) areas: Vec<TitleArea>, |
| 24 | /// The paragraph each outline holding no text shows after its objects, not stored. | 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 | pub(crate) enum Content { | 28 | pub(crate) enum Content { |
| ... | @@ -95,9 +95,8 @@ impl Content { | ... | @@ -95,9 +95,8 @@ impl Content { |
| 95 | let Self::File { source, layout } = self else { | 95 | let Self::File { source, layout } = self else { |
| 96 | return None; | 96 | return None; |
| 97 | }; | 97 | }; |
| 98 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | 98 | let origin = crate::origin(&source.layout); |
| 99 | let [x0, y0, x1, y1] = layout.bounds(); | 99 | Some((source, crate::translated(layout.bounds(), origin))) |
| 100 | Some((source, [x + x0, y + y0, x + x1, y + y1])) | ||
| 101 | } | 100 | } |
| 102 | 101 | ||
| 103 | /// A tagged picture or file: its identity, note tags and bounds in page points. | 102 | /// A tagged picture or file: its identity, note tags and bounds in page points. |
| ... | @@ -106,7 +105,7 @@ impl Content { | ... | @@ -106,7 +105,7 @@ impl Content { |
| 106 | ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { | 105 | ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { |
| 107 | let (id, tags, bounds) = match self { | 106 | let (id, tags, bounds) = match self { |
| 108 | Self::Image(image) => { | 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 | let [width, height] = crate::outline::image_size(image)?; | 109 | let [width, height] = crate::outline::image_size(image)?; |
| 111 | (image.id, &image.tags, [x, y, x + width, y + height]) | 110 | (image.id, &image.tags, [x, y, x + width, y + height]) |
| 112 | } | 111 | } |
| ... | @@ -143,14 +142,11 @@ impl ReadOnlyObject { | ... | @@ -143,14 +142,11 @@ impl ReadOnlyObject { |
| 143 | [0.0; 2] | 142 | [0.0; 2] |
| 144 | }; | 143 | }; |
| 145 | let layout = source.layout(); | 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 | let width = layout.max_width.unwrap_or(160.0); | 145 | let width = layout.max_width.unwrap_or(160.0); |
| 149 | let height = layout.max_height.unwrap_or(42.0); | 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 | return Err(EditorError::InvalidGeometry); | 148 | return Err(EditorError::InvalidGeometry); |
| 152 | } | 149 | } |
| 153 | let width = width.max(160.0); | ||
| 154 | let label = engine.layout( | 150 | let label = engine.layout( |
| 155 | &Paragraph::new( | 151 | &Paragraph::new( |
| 156 | message.into(), | 152 | message.into(), |
| ... | @@ -160,21 +156,21 @@ impl ReadOnlyObject { | ... | @@ -160,21 +156,21 @@ impl ReadOnlyObject { |
| 160 | ..Default::default() | 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); | 161 | let object = Box::new(Self { |
| 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 { | ||
| 171 | source, | 162 | source, |
| 172 | message, | 163 | message, |
| 173 | label, | 164 | label, |
| 174 | offset, | 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 | let layout = self.source.layout(); | 174 | let layout = self.source.layout(); |
| 179 | let x = layout.x.unwrap_or(0.0) + self.offset[0]; | 175 | let x = layout.x.unwrap_or(0.0) + self.offset[0]; |
| 180 | let y = layout.y.unwrap_or(0.0) + self.offset[1]; | 176 | let y = layout.y.unwrap_or(0.0) + self.offset[1]; |
| ... | @@ -206,10 +202,7 @@ pub(crate) fn build( | ... | @@ -206,10 +202,7 @@ pub(crate) fn build( |
| 206 | for object in std::mem::take(&mut page.objects) { | 202 | for object in std::mem::take(&mut page.objects) { |
| 207 | match &object { | 203 | match &object { |
| 208 | PageObject::Outline(outline) => { | 204 | PageObject::Outline(outline) => { |
| 209 | let origin = [ | 205 | let origin = crate::origin(&outline.layout); |
| 210 | outline.layout.x.unwrap_or(0.0), | ||
| 211 | outline.layout.y.unwrap_or(0.0), | ||
| 212 | ]; | ||
| 213 | if origin.iter().any(|v| !v.is_finite()) { | 206 | if origin.iter().any(|v| !v.is_finite()) { |
| 214 | return Err(EditorError::InvalidGeometry); | 207 | return Err(EditorError::InvalidGeometry); |
| 215 | } | 208 | } |
| ... | @@ -377,10 +370,7 @@ pub(crate) fn build( | ... | @@ -377,10 +370,7 @@ pub(crate) fn build( |
| 377 | objects.push(Content::unavailable(object, engine)?); | 370 | objects.push(Content::unavailable(object, engine)?); |
| 378 | continue; | 371 | continue; |
| 379 | }; | 372 | }; |
| 380 | let origin = [ | 373 | let origin = crate::origin(&source.layout); |
| 381 | source.layout.x.unwrap_or(0.0), | ||
| 382 | source.layout.y.unwrap_or(0.0), | ||
| 383 | ]; | ||
| 384 | if origin.iter().any(|v| !v.is_finite()) | 374 | if origin.iter().any(|v| !v.is_finite()) |
| 385 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) | 375 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) |
| 386 | || !(origin[0] + size[0]).is_finite() | 376 | || !(origin[0] + size[0]).is_finite() |
| ... | @@ -435,12 +425,7 @@ pub(crate) fn build( | ... | @@ -435,12 +425,7 @@ pub(crate) fn build( |
| 435 | /// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page | 425 | /// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page |
| 436 | /// points. | 426 | /// points. |
| 437 | pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { | 427 | pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { |
| 438 | let [x, y] = ink_offset(ink); | 428 | stroke_bounds(ink).map(|bounds| crate::translated(bounds, crate::origin(&ink.layout))) |
| 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)] | ||
| 444 | } | 429 | } |
| 445 | 430 | ||
| 446 | /// The painted extent of every stroke before the drawing's offset. | 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,7 +259,7 @@ const BUTTON: [f32; 2] = [10.5, 24.0]; |
| 259 | 259 | ||
| 260 | impl CanvasEditor { | 260 | impl CanvasEditor { |
| 261 | /// The recording `id` names, in an outline or on the page. | 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 | let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id); | 263 | let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id); |
| 264 | self.objects | 264 | self.objects |
| 265 | .iter() | 265 | .iter() |
| ... | @@ -293,7 +293,7 @@ impl CanvasEditor { | ... | @@ -293,7 +293,7 @@ impl CanvasEditor { |
| 293 | /// The play button OneNote shows for document point `point`: beside the linked note or | 293 | /// The play button OneNote shows for document point `point`: beside the linked note or |
| 294 | /// the recording level with it, over its outline or in the margin before it. Gives the | 294 | /// the recording level with it, over its outline or in the margin before it. Gives the |
| 295 | /// button's square, the recording and the moment it plays from. | 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 | let [x, y] = point; | 297 | let [x, y] = point; |
| 298 | let outline = self.visible_outlines().find(|outline| { | 298 | let outline = self.visible_outlines().find(|outline| { |
| 299 | let bounds = outline.bounds(); | 299 | let bounds = outline.bounds(); |
crates/canvas/src/editor/space.rs+6-17| ... | @@ -68,7 +68,7 @@ impl CanvasEditor { | ... | @@ -68,7 +68,7 @@ impl CanvasEditor { |
| 68 | layout, | 68 | layout, |
| 69 | below_title: None, | 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 | (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) | 72 | (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) |
| 73 | } | 73 | } |
| 74 | page::Content::Image(image) => { | 74 | page::Content::Image(image) => { |
| ... | @@ -109,13 +109,13 @@ impl CanvasEditor { | ... | @@ -109,13 +109,13 @@ impl CanvasEditor { |
| 109 | 109 | ||
| 110 | /// How far back from `line` Insert Space can take the page along `axis`: never over what | 110 | /// How far back from `line` Insert Space can take the page along `axis`: never over what |
| 111 | /// stays before the line, so at most zero. | 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 | (self.spaced(axis, line).1 - line).min(0.0) | 113 | (self.spaced(axis, line).1 - line).min(0.0) |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along | 116 | /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along |
| 117 | /// `axis` moves. | 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 | self.spaced(axis, line) | 119 | self.spaced(axis, line) |
| 120 | .0 | 120 | .0 |
| 121 | .into_iter() | 121 | .into_iter() |
| ... | @@ -156,7 +156,7 @@ impl CanvasEditor { | ... | @@ -156,7 +156,7 @@ impl CanvasEditor { |
| 156 | let layout = self.object_layout_mut(id).unwrap(); | 156 | let layout = self.object_layout_mut(id).unwrap(); |
| 157 | // Undo restores an unset coordinate as the zero it stands for, as ops | 157 | // Undo restores an unset coordinate as the zero it stands for, as ops |
| 158 | // cannot unset one. | 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 | let previous = origin.map(Some); | 160 | let previous = origin.map(Some); |
| 161 | [layout.x, layout.y] = shift(origin).map(Some); | 161 | [layout.x, layout.y] = shift(origin).map(Some); |
| 162 | self.undo.push(History::Position { | 162 | self.undo.push(History::Position { |
| ... | @@ -182,27 +182,16 @@ impl CanvasEditor { | ... | @@ -182,27 +182,16 @@ impl CanvasEditor { |
| 182 | } | 182 | } |
| 183 | }; | 183 | }; |
| 184 | if let Err(error) = result { | 184 | if let Err(error) = result { |
| 185 | self.group(depth); | 185 | self.group(depth, false); |
| 186 | return Err(error); | 186 | return Err(error); |
| 187 | } | 187 | } |
| 188 | } | 188 | } |
| 189 | self.group(depth); | 189 | self.group(depth, false); |
| 190 | self.redo.clear(); | 190 | self.redo.clear(); |
| 191 | self.preferred_x = None; | 191 | self.preferred_x = None; |
| 192 | Ok(true) | 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 | /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which | 195 | /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which |
| 207 | /// takes the rest of the outline's layout; returns each root `part` then holds with the | 196 | /// takes the rest of the outline's layout; returns each root `part` then holds with the |
| 208 | /// parent it had. | 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,16 +65,59 @@ fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> { |
| 65 | Default::default(), | 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 | Ok(TableCell { | 77 | Ok(TableCell { |
| 69 | id: new_id()?, | 78 | id: new_id()?, |
| 70 | layout: Default::default(), | 79 | layout: Default::default(), |
| 71 | indents: source.indents.clone(), | 80 | indents, |
| 72 | shading: source.shading, | 81 | shading, |
| 73 | paragraphs: vec![node], | 82 | paragraphs, |
| 74 | unsupported: Vec::new(), | 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 | fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> { | 121 | fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> { |
| 79 | let mut content = leaves(&cell.paragraphs, None); | 122 | let mut content = leaves(&cell.paragraphs, None); |
| 80 | let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?; | 123 | let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?; |
| ... | @@ -250,7 +293,11 @@ impl CanvasEditor { | ... | @@ -250,7 +293,11 @@ impl CanvasEditor { |
| 250 | impl TextOutline { | 293 | impl TextOutline { |
| 251 | /// The table column whose right border lies within `reach` of outline-local `point`, as | 294 | /// The table column whose right border lies within `reach` of outline-local `point`, as |
| 252 | /// `(table, column, width)`. | 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 | let columns = descendants(self.document.nodes(), None) | 301 | let columns = descendants(self.document.nodes(), None) |
| 255 | .filter_map(|(_, _, node)| match &node.content { | 302 | .filter_map(|(_, _, node)| match &node.content { |
| 256 | ParagraphContent::Table(table) => Some((table.id, table.columns.len())), | 303 | ParagraphContent::Table(table) => Some((table.id, table.columns.len())), |
| ... | @@ -276,7 +323,7 @@ impl TextOutline { | ... | @@ -276,7 +323,7 @@ impl TextOutline { |
| 276 | impl CanvasEditor { | 323 | impl CanvasEditor { |
| 277 | /// The active outline with column `column` of `table` `width` wide, as a border drag | 324 | /// The active outline with column `column` of `table` `width` wide, as a border drag |
| 278 | /// shows it before release. | 325 | /// shows it before release. |
| 279 | pub fn preview_column( | 326 | pub(crate) fn preview_column( |
| 280 | &self, | 327 | &self, |
| 281 | engine: &mut TextEngine, | 328 | engine: &mut TextEngine, |
| 282 | table: ExGuid, | 329 | table: ExGuid, |
| ... | @@ -383,53 +430,23 @@ impl CanvasEditor { | ... | @@ -383,53 +430,23 @@ impl CanvasEditor { |
| 383 | let text = &node.text().unwrap().text; | 430 | let text = &node.text().unwrap().text; |
| 384 | let end = text.utf16_offset(text.text().len())?; | 431 | let end = text.utf16_offset(text.text().len())?; |
| 385 | let format = text.format_at(caret.offset)?; | 432 | let format = text.format_at(caret.offset)?; |
| 386 | let cell = || -> Result<TableCell, EditError> { | 433 | let empty = || -> Result<TableCell, EditError> { |
| 387 | let mut paragraph = crate::document::node( | 434 | let mut paragraph = crate::document::node( |
| 388 | Paragraph::new(String::new(), format.clone()), | 435 | Paragraph::new(String::new(), format.clone()), |
| 389 | node.format.clone(), | 436 | node.format.clone(), |
| 390 | )?; | 437 | )?; |
| 391 | paragraph.style = node.style; | 438 | paragraph.style = node.style; |
| 392 | Ok(TableCell { | 439 | new_cell(outline.indents.clone(), None, vec![paragraph]) |
| 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 | }), | ||
| 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 | let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id); | 450 | let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id); |
| 434 | let childless = !outline.document.container(container)?.iter().any(parent); | 451 | let childless = !outline.document.container(container)?.iter().any(parent); |
| 435 | let (edit, first) = if end == 0 && childless { | 452 | let (edit, first) = if end == 0 && childless { |
| ... | @@ -489,12 +506,8 @@ impl CanvasEditor { | ... | @@ -489,12 +506,8 @@ impl CanvasEditor { |
| 489 | let format = source.text().unwrap().text.format_at(focus.offset)?; | 506 | let format = source.text().unwrap().text.format_at(focus.offset)?; |
| 490 | let mut split = outline.document.replace( | 507 | let mut split = outline.document.replace( |
| 491 | focus..focus, | 508 | focus..focus, |
| 492 | vec![ | 509 | vec![Paragraph::new(String::new(), format.clone()); 2], |
| 493 | Paragraph::new(String::new(), format.clone()), | ||
| 494 | Paragraph::new(String::new(), format.clone()), | ||
| 495 | ], | ||
| 496 | )?; | 510 | )?; |
| 497 | let id = new_id()?; | ||
| 498 | // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ | 511 | // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ |
| 499 | // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). | 512 | // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). |
| 500 | let children = split.replacement.split_off(2); | 513 | let children = split.replacement.split_off(2); |
| ... | @@ -511,44 +524,20 @@ impl CanvasEditor { | ... | @@ -511,44 +524,20 @@ impl CanvasEditor { |
| 511 | paragraph.level = 1; | 524 | paragraph.level = 1; |
| 512 | paragraph.parent = None; | 525 | paragraph.parent = None; |
| 513 | paragraph.lists.clear(); | 526 | paragraph.lists.clear(); |
| 514 | Ok(TableCell { | 527 | new_cell(outline.indents.clone(), None, vec![paragraph]) |
| 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 | }) | ||
| 522 | }) | 528 | }) |
| 523 | .collect::<Result<Vec<_>, EditError>>()?; | 529 | .collect::<Result<Vec<_>, EditError>>()?; |
| 530 | let row = TableRow { | ||
| 531 | id: new_id()?, | ||
| 532 | cells, | ||
| 533 | }; | ||
| 524 | let wrapper = PageParagraph { | 534 | let wrapper = PageParagraph { |
| 525 | id, | ||
| 526 | parent: source.parent, | ||
| 527 | level: source.level, | ||
| 528 | style: None, | ||
| 529 | format: source.format.clone(), | ||
| 530 | lists, | 535 | lists, |
| 531 | tags, | 536 | tags, |
| 532 | media: Default::default(), | ||
| 533 | collapsed, | 537 | collapsed, |
| 534 | content: ParagraphContent::Table(onestore::page::Table { | 538 | ..table_paragraph(source, 2, vec![row])? |
| 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 | }), | ||
| 551 | }; | 539 | }; |
| 540 | let id = wrapper.id; | ||
| 552 | split.replacement.push(wrapper); | 541 | split.replacement.push(wrapper); |
| 553 | split | 542 | split |
| 554 | .replacement | 543 | .replacement |
| ... | @@ -598,22 +587,12 @@ impl CanvasEditor { | ... | @@ -598,22 +587,12 @@ impl CanvasEditor { |
| 598 | let format = source.text().unwrap().text.format_at(focus.offset)?; | 587 | let format = source.text().unwrap().text.format_at(focus.offset)?; |
| 599 | let split = outline.document.replace( | 588 | let split = outline.document.replace( |
| 600 | focus..focus, | 589 | focus..focus, |
| 601 | vec![ | 590 | vec![Paragraph::new(String::new(), format.clone()); 2], |
| 602 | Paragraph::new(String::new(), format.clone()), | ||
| 603 | Paragraph::new(String::new(), format.clone()), | ||
| 604 | ], | ||
| 605 | )?; | 591 | )?; |
| 606 | let cell = &mut table.rows[0].cells[location.column]; | 592 | let source = &mut table.rows[0].cells[location.column]; |
| 607 | cell.paragraphs.splice(split.range, split.replacement); | 593 | source.paragraphs.splice(split.range, split.replacement); |
| 608 | let paragraphs = cell.paragraphs.split_off(local + 1); | 594 | let paragraphs = source.paragraphs.split_off(local + 1); |
| 609 | let target = TableCell { | 595 | let target = new_cell(source.indents.clone(), source.shading, paragraphs)?; |
| 610 | id: new_id()?, | ||
| 611 | layout: Default::default(), | ||
| 612 | indents: cell.indents.clone(), | ||
| 613 | shading: cell.shading, | ||
| 614 | paragraphs, | ||
| 615 | unsupported: Vec::new(), | ||
| 616 | }; | ||
| 617 | table.rows[0].cells.push(target); | 596 | table.rows[0].cells.push(target); |
| 618 | table.columns.push(TableColumn { | 597 | table.columns.push(TableColumn { |
| 619 | width: COLUMN_WIDTH, | 598 | width: COLUMN_WIDTH, |
| ... | @@ -722,7 +701,7 @@ impl CanvasEditor { | ... | @@ -722,7 +701,7 @@ impl CanvasEditor { |
| 722 | let ParagraphContent::Table(table) = &mut wrapper.content else { | 701 | let ParagraphContent::Table(table) = &mut wrapper.content else { |
| 723 | unreachable!() | 702 | unreachable!() |
| 724 | }; | 703 | }; |
| 725 | let (replacement, next) = if exit { | 704 | let replacement = if exit { |
| 726 | table.rows.pop(); | 705 | table.rows.pop(); |
| 727 | let blank = || { | 706 | let blank = || { |
| 728 | Ok::<_, EditError>(PageParagraph { | 707 | Ok::<_, EditError>(PageParagraph { |
| ... | @@ -732,14 +711,7 @@ impl CanvasEditor { | ... | @@ -732,14 +711,7 @@ impl CanvasEditor { |
| 732 | ..self.blank_paragraph(source)? | 711 | ..self.blank_paragraph(source)? |
| 733 | }) | 712 | }) |
| 734 | }; | 713 | }; |
| 735 | let (first, second) = (blank()?, blank()?); | 714 | vec![wrapper, blank()?, blank()?] |
| 736 | ( | ||
| 737 | vec![wrapper, first, second], | ||
| 738 | TextPosition { | ||
| 739 | paragraph: focus.paragraph + 1, | ||
| 740 | offset: 0, | ||
| 741 | }, | ||
| 742 | ) | ||
| 743 | } else { | 715 | } else { |
| 744 | let cells = row | 716 | let cells = row |
| 745 | .cells | 717 | .cells |
| ... | @@ -750,13 +722,11 @@ impl CanvasEditor { | ... | @@ -750,13 +722,11 @@ impl CanvasEditor { |
| 750 | id: new_id()?, | 722 | id: new_id()?, |
| 751 | cells, | 723 | cells, |
| 752 | }); | 724 | }); |
| 753 | ( | 725 | vec![wrapper] |
| 754 | vec![wrapper], | 726 | }; |
| 755 | TextPosition { | 727 | let next = TextPosition { |
| 756 | paragraph: focus.paragraph + 1, | 728 | paragraph: focus.paragraph + 1, |
| 757 | offset: 0, | 729 | offset: 0, |
| 758 | }, | ||
| 759 | ) | ||
| 760 | }; | 730 | }; |
| 761 | self.commit( | 731 | self.commit( |
| 762 | engine, | 732 | engine, |
crates/canvas/src/gpu/page.rs+50-105| ... | @@ -301,7 +301,7 @@ impl PageScene { | ... | @@ -301,7 +301,7 @@ impl PageScene { |
| 301 | 301 | ||
| 302 | /// Follows `editor` after an edit or undo brought page-level pictures the scene has not | 302 | /// Follows `editor` after an edit or undo brought page-level pictures the scene has not |
| 303 | /// seen, as `refresh` does. | 303 | /// seen, as `refresh` does. |
| 304 | pub fn follow( | 304 | pub(crate) fn follow( |
| 305 | &mut self, | 305 | &mut self, |
| 306 | editor: &mut CanvasEditor, | 306 | editor: &mut CanvasEditor, |
| 307 | engine: &mut TextEngine, | 307 | engine: &mut TextEngine, |
| ... | @@ -415,7 +415,7 @@ impl PageScene { | ... | @@ -415,7 +415,7 @@ impl PageScene { |
| 415 | /// this thread; `waker` is woken when one lands and the page should be drawn again. | 415 | /// this thread; `waker` is woken when one lands and the page should be drawn again. |
| 416 | /// Call before collecting each frame's primitives. True once everything in view shows | 416 | /// Call before collecting each frame's primitives. True once everything in view shows |
| 417 | /// its raster for this paper and scale. | 417 | /// its raster for this paper and scale. |
| 418 | pub fn update_pictures( | 418 | pub(crate) fn update_pictures( |
| 419 | &mut self, | 419 | &mut self, |
| 420 | editor: Option<&CanvasEditor>, | 420 | editor: Option<&CanvasEditor>, |
| 421 | view: [f32; 4], | 421 | view: [f32; 4], |
| ... | @@ -468,37 +468,32 @@ impl PageScene { | ... | @@ -468,37 +468,32 @@ impl PageScene { |
| 468 | [x, y]: [f32; 2], | 468 | [x, y]: [f32; 2], |
| 469 | rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, | 469 | rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, |
| 470 | ) { | 470 | ) { |
| 471 | rects.extend(layout.objects.iter().map(|object| { | 471 | rects.extend( |
| 472 | let [x0, y0, x1, y1] = object.rect; | 472 | layout |
| 473 | (object.id, [x0 + x, y0 + y, x1 + x, y1 + y]) | 473 | .objects |
| 474 | })); | 474 | .iter() |
| 475 | .map(|object| (object.id, crate::translated(object.rect, [x, y]))), | ||
| 476 | ); | ||
| 475 | } | 477 | } |
| 476 | let mut rects = Vec::new(); | 478 | let mut rects = Vec::new(); |
| 477 | for content in self.objects(editor).into_iter().flatten() { | 479 | for content in self.objects(editor).into_iter().flatten() { |
| 478 | match content { | 480 | match content { |
| 479 | Content::Image(source) => { | 481 | Content::Image(source) => { |
| 480 | let [x, y] = [ | 482 | let [x, y] = crate::origin(&source.layout); |
| 481 | source.layout.x.unwrap_or(0.0), | ||
| 482 | source.layout.y.unwrap_or(0.0), | ||
| 483 | ]; | ||
| 484 | if let Some([width, height]) = crate::outline::image_size(source) { | 483 | if let Some([width, height]) = crate::outline::image_size(source) { |
| 485 | rects.push((source.id, [x, y, x + width, y + height])); | 484 | rects.push((source.id, [x, y, x + width, y + height])); |
| 486 | } | 485 | } |
| 487 | } | 486 | } |
| 488 | Content::File { source, layout } => { | 487 | Content::File { source, layout } => { |
| 489 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | 488 | let origin = crate::origin(&source.layout); |
| 490 | let [x0, y0, x1, y1] = layout.rect; | 489 | rects.push((source.id, crate::translated(layout.rect, origin))); |
| 491 | rects.push((source.id, [x + x0, y + y0, x + x1, y + y1])); | ||
| 492 | } | 490 | } |
| 493 | Content::Outline { | 491 | Content::Outline { |
| 494 | source, | 492 | source, |
| 495 | layout, | 493 | layout, |
| 496 | below_title, | 494 | below_title, |
| 497 | } => { | 495 | } => { |
| 498 | let origin = [ | 496 | let origin = crate::origin(&source.layout); |
| 499 | source.layout.x.unwrap_or(0.0), | ||
| 500 | source.layout.y.unwrap_or(0.0), | ||
| 501 | ]; | ||
| 502 | if let Ok(origin) = outline_origin(origin, *below_title, editor) { | 497 | if let Ok(origin) = outline_origin(origin, *below_title, editor) { |
| 503 | outline(layout, origin, &mut rects); | 498 | outline(layout, origin, &mut rects); |
| 504 | } | 499 | } |
| ... | @@ -518,7 +513,7 @@ impl PageScene { | ... | @@ -518,7 +513,7 @@ impl PageScene { |
| 518 | 513 | ||
| 519 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. | 514 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. |
| 520 | /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. | 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 | &'a self, | 517 | &'a self, |
| 523 | outline: &'a crate::outline::OutlineLayout, | 518 | outline: &'a crate::outline::OutlineLayout, |
| 524 | origin: [f32; 2], | 519 | origin: [f32; 2], |
| ... | @@ -541,15 +536,9 @@ impl PageScene { | ... | @@ -541,15 +536,9 @@ impl PageScene { |
| 541 | paper: Paper, | 536 | paper: Paper, |
| 542 | primitives: &mut Vec<Primitive<'a>>, | 537 | primitives: &mut Vec<Primitive<'a>>, |
| 543 | ) { | 538 | ) { |
| 544 | let [x0, y0, x1, y1] = object.rect; | ||
| 545 | let rect = match moving { | 539 | let rect = match moving { |
| 546 | Some((id, rect)) if id == object.id => rect, | 540 | Some((id, rect)) if id == object.id => rect, |
| 547 | _ => [ | 541 | _ => crate::translated(object.rect, origin), |
| 548 | x0 + origin[0], | ||
| 549 | y0 + origin[1], | ||
| 550 | x1 + origin[0], | ||
| 551 | y1 + origin[1], | ||
| 552 | ], | ||
| 553 | }; | 542 | }; |
| 554 | let picture = self.pictures.get(&object.id); | 543 | let picture = self.pictures.get(&object.id); |
| 555 | if let Some(image) = picture.and_then(Picture::image) { | 544 | if let Some(image) = picture.and_then(Picture::image) { |
| ... | @@ -644,7 +633,7 @@ impl PageScene { | ... | @@ -644,7 +633,7 @@ impl PageScene { |
| 644 | 633 | ||
| 645 | /// Bounds of noneditable content in page coordinates, each with whether OneNote's view | 634 | /// Bounds of noneditable content in page coordinates, each with whether OneNote's view |
| 646 | /// leaves room beyond it: a picture's corner is as far as the view reaches. | 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 | &'a self, | 637 | &'a self, |
| 649 | editor: &'a CanvasEditor, | 638 | editor: &'a CanvasEditor, |
| 650 | ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a { | 639 | ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a { |
| ... | @@ -657,15 +646,9 @@ impl PageScene { | ... | @@ -657,15 +646,9 @@ impl PageScene { |
| 657 | layout, | 646 | layout, |
| 658 | below_title, | 647 | below_title, |
| 659 | } => { | 648 | } => { |
| 660 | let origin = outline_origin( | 649 | let origin = |
| 661 | [ | 650 | outline_origin(crate::origin(&source.layout), *below_title, Some(editor)) |
| 662 | source.layout.x.unwrap_or(0.0), | 651 | .ok()?; |
| 663 | source.layout.y.unwrap_or(0.0), | ||
| 664 | ], | ||
| 665 | *below_title, | ||
| 666 | Some(editor), | ||
| 667 | ) | ||
| 668 | .ok()?; | ||
| 669 | Some(( | 652 | Some(( |
| 670 | [ | 653 | [ |
| 671 | origin[0], | 654 | origin[0], |
| ... | @@ -679,10 +662,7 @@ impl PageScene { | ... | @@ -679,10 +662,7 @@ impl PageScene { |
| 679 | Content::Date { below_title } => { | 662 | Content::Date { below_title } => { |
| 680 | let date = editor.date()?; | 663 | let date = editor.date()?; |
| 681 | let origin = outline_origin( | 664 | let origin = outline_origin( |
| 682 | [ | 665 | crate::origin(&date.source().layout), |
| 683 | date.source().layout.x.unwrap_or(0.0), | ||
| 684 | date.source().layout.y.unwrap_or(0.0), | ||
| 685 | ], | ||
| 686 | *below_title, | 666 | *below_title, |
| 687 | Some(editor), | 667 | Some(editor), |
| 688 | ) | 668 | ) |
| ... | @@ -699,7 +679,7 @@ impl PageScene { | ... | @@ -699,7 +679,7 @@ impl PageScene { |
| 699 | )) | 679 | )) |
| 700 | } | 680 | } |
| 701 | Content::Image(source) => { | 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 | let [width, height] = crate::outline::image_size(source)?; | 683 | let [width, height] = crate::outline::image_size(source)?; |
| 704 | Some(([x, y, x + width, y + height], false)) | 684 | Some(([x, y, x + width, y + height], false)) |
| 705 | } | 685 | } |
| ... | @@ -710,7 +690,7 @@ impl PageScene { | ... | @@ -710,7 +690,7 @@ impl PageScene { |
| 710 | }) | 690 | }) |
| 711 | } | 691 | } |
| 712 | 692 | ||
| 713 | pub fn date_fields( | 693 | pub(crate) fn date_fields( |
| 714 | &self, | 694 | &self, |
| 715 | editor: &CanvasEditor, | 695 | editor: &CanvasEditor, |
| 716 | ) -> impl Iterator<Item = (DateField, [f32; 4])> { | 696 | ) -> impl Iterator<Item = (DateField, [f32; 4])> { |
| ... | @@ -721,13 +701,9 @@ impl PageScene { | ... | @@ -721,13 +701,9 @@ impl PageScene { |
| 721 | .into_iter() | 701 | .into_iter() |
| 722 | .flatten() | 702 | .flatten() |
| 723 | .find_map(|object| match object { | 703 | .find_map(|object| match object { |
| 724 | Content::Date { below_title } => Some(( | 704 | Content::Date { below_title } => { |
| 725 | [ | 705 | Some((crate::origin(&date.source().layout), *below_title)) |
| 726 | date.source().layout.x.unwrap_or(0.0), | 706 | } |
| 727 | date.source().layout.y.unwrap_or(0.0), | ||
| 728 | ], | ||
| 729 | *below_title, | ||
| 730 | )), | ||
| 731 | _ => None, | 707 | _ => None, |
| 732 | }) | 708 | }) |
| 733 | && let Ok(origin) = outline_origin(origin, title, Some(editor)) | 709 | && let Ok(origin) = outline_origin(origin, title, Some(editor)) |
| ... | @@ -794,10 +770,7 @@ impl PageScene { | ... | @@ -794,10 +770,7 @@ impl PageScene { |
| 794 | } | 770 | } |
| 795 | } | 771 | } |
| 796 | Content::Image(source) if !source.background => { | 772 | Content::Image(source) if !source.background => { |
| 797 | let [x, y] = [ | 773 | let [x, y] = crate::origin(&source.layout); |
| 798 | source.layout.x.unwrap_or(0.0), | ||
| 799 | source.layout.y.unwrap_or(0.0), | ||
| 800 | ]; | ||
| 801 | if let Some([width, height]) = crate::outline::image_size(source) | 774 | if let Some([width, height]) = crate::outline::image_size(source) |
| 802 | && (x..=x + width).contains(&point[0]) | 775 | && (x..=x + width).contains(&point[0]) |
| 803 | && (y..=y + height).contains(&point[1]) | 776 | && (y..=y + height).contains(&point[1]) |
| ... | @@ -846,14 +819,7 @@ impl PageScene { | ... | @@ -846,14 +819,7 @@ impl PageScene { |
| 846 | layout, | 819 | layout, |
| 847 | below_title, | 820 | below_title, |
| 848 | } => { | 821 | } => { |
| 849 | let [x, y] = outline_origin( | 822 | let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?; |
| 850 | [ | ||
| 851 | source.layout.x.unwrap_or(0.0), | ||
| 852 | source.layout.y.unwrap_or(0.0), | ||
| 853 | ], | ||
| 854 | *below_title, | ||
| 855 | None, | ||
| 856 | )?; | ||
| 857 | cover([x, y, x + layout.size[0], y + layout.size[1]], false); | 823 | cover([x, y, x + layout.size[0], y + layout.size[1]], false); |
| 858 | for paragraph in &layout.paragraphs { | 824 | for paragraph in &layout.paragraphs { |
| 859 | let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; | 825 | let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; |
| ... | @@ -869,10 +835,7 @@ impl PageScene { | ... | @@ -869,10 +835,7 @@ impl PageScene { |
| 869 | } | 835 | } |
| 870 | } | 836 | } |
| 871 | Content::Image(source) => { | 837 | Content::Image(source) => { |
| 872 | let [x, y] = [ | 838 | let [x, y] = crate::origin(&source.layout); |
| 873 | source.layout.x.unwrap_or(0.0), | ||
| 874 | source.layout.y.unwrap_or(0.0), | ||
| 875 | ]; | ||
| 876 | let size = match self.backgrounds.get(&source.id) { | 839 | let size = match self.backgrounds.get(&source.id) { |
| 877 | Some(art) => Some(art.size), | 840 | Some(art) => Some(art.size), |
| 878 | None => crate::outline::image_size(source), | 841 | None => crate::outline::image_size(source), |
| ... | @@ -912,7 +875,7 @@ impl PageScene { | ... | @@ -912,7 +875,7 @@ impl PageScene { |
| 912 | 875 | ||
| 913 | /// `moving` draws one picture at a previewed rectangle instead of its stored layout. | 876 | /// `moving` draws one picture at a previewed rectangle instead of its stored layout. |
| 914 | /// Outlines the editor does not hold draw their tags' symbols. | 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 | &'a self, | 879 | &'a self, |
| 917 | primitives: &mut Vec<Primitive<'a>>, | 880 | primitives: &mut Vec<Primitive<'a>>, |
| 918 | offset: [f32; 2], | 881 | offset: [f32; 2], |
| ... | @@ -927,39 +890,16 @@ impl PageScene { | ... | @@ -927,39 +890,16 @@ impl PageScene { |
| 927 | source, | 890 | source, |
| 928 | below_title, | 891 | below_title, |
| 929 | .. | 892 | .. |
| 930 | } => outline_origin( | 893 | } => outline_origin(crate::origin(&source.layout), *below_title, editor)?, |
| 931 | [ | ||
| 932 | source.layout.x.unwrap_or(0.0), | ||
| 933 | source.layout.y.unwrap_or(0.0), | ||
| 934 | ], | ||
| 935 | *below_title, | ||
| 936 | editor, | ||
| 937 | )?, | ||
| 938 | Content::Date { below_title } => { | 894 | Content::Date { below_title } => { |
| 939 | let date = editor | 895 | let date = editor |
| 940 | .and_then(CanvasEditor::date) | 896 | .and_then(CanvasEditor::date) |
| 941 | .ok_or(SceneError::MissingOutline)?; | 897 | .ok_or(SceneError::MissingOutline)?; |
| 942 | outline_origin( | 898 | outline_origin(crate::origin(&date.source().layout), *below_title, editor)? |
| 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 | )? | ||
| 950 | } | 899 | } |
| 951 | Content::Image(source) => [ | 900 | Content::Image(source) => crate::origin(&source.layout), |
| 952 | source.layout.x.unwrap_or(0.0), | ||
| 953 | source.layout.y.unwrap_or(0.0), | ||
| 954 | ], | ||
| 955 | Content::ReadOnly(object) => { | 901 | Content::ReadOnly(object) => { |
| 956 | let [x0, y0, x1, y1] = object.rect(); | 902 | let rect = crate::translated(object.rect(), offset); |
| 957 | let rect = [ | ||
| 958 | x0 + offset[0], | ||
| 959 | y0 + offset[1], | ||
| 960 | x1 + offset[0], | ||
| 961 | y1 + offset[1], | ||
| 962 | ]; | ||
| 963 | append_placeholder(rect, paper, primitives); | 903 | append_placeholder(rect, paper, primitives); |
| 964 | primitives.push(Primitive::Text { | 904 | primitives.push(Primitive::Text { |
| 965 | clip: None, | 905 | clip: None, |
| ... | @@ -974,7 +914,7 @@ impl PageScene { | ... | @@ -974,7 +914,7 @@ impl PageScene { |
| 974 | continue; | 914 | continue; |
| 975 | } | 915 | } |
| 976 | Content::Ink(ink) => { | 916 | Content::Ink(ink) => { |
| 977 | let [x, y] = crate::editor::page::ink_offset(ink); | 917 | let [x, y] = crate::origin(&ink.layout); |
| 978 | let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id)); | 918 | let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id)); |
| 979 | let offset = [offset[0] + x + dx, offset[1] + y + dy]; | 919 | let offset = [offset[0] + x + dx, offset[1] + y + dy]; |
| 980 | append_ink(ink, offset, paper.ink, primitives); | 920 | append_ink(ink, offset, paper.ink, primitives); |
| ... | @@ -984,7 +924,7 @@ impl PageScene { | ... | @@ -984,7 +924,7 @@ impl PageScene { |
| 984 | // A dragged file draws where its column would land. | 924 | // A dragged file draws where its column would land. |
| 985 | let [x, y] = match moving { | 925 | let [x, y] = match moving { |
| 986 | Some((id, [x0, y0, ..])) if id == source.id => [x0, y0], | 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 | self.append_object( | 929 | self.append_object( |
| 990 | layout, | 930 | layout, |
| ... | @@ -1043,12 +983,7 @@ impl PageScene { | ... | @@ -1043,12 +983,7 @@ impl PageScene { |
| 1043 | continue; | 983 | continue; |
| 1044 | }; | 984 | }; |
| 1045 | let rect = match moving { | 985 | let rect = match moving { |
| 1046 | Some((id, [x0, y0, x1, y1])) if id == source.id => [ | 986 | Some((id, rect)) if id == source.id => crate::translated(rect, offset), |
| 1047 | x0 + offset[0], | ||
| 1048 | y0 + offset[1], | ||
| 1049 | x1 + offset[0], | ||
| 1050 | y1 + offset[1], | ||
| 1051 | ], | ||
| 1052 | _ => [ | 987 | _ => [ |
| 1053 | object_origin[0], | 988 | object_origin[0], |
| 1054 | object_origin[1], | 989 | object_origin[1], |
| ... | @@ -1120,7 +1055,7 @@ impl PageScene { | ... | @@ -1120,7 +1055,7 @@ impl PageScene { |
| 1120 | 1055 | ||
| 1121 | impl crate::outline::OutlineLayout { | 1056 | impl crate::outline::OutlineLayout { |
| 1122 | /// Paragraphs that may paint between outline-local `rows`, with their indices. | 1057 | /// Paragraphs that may paint between outline-local `rows`, with their indices. |
| 1123 | pub fn visible( | 1058 | pub(crate) fn visible( |
| 1124 | &self, | 1059 | &self, |
| 1125 | rows: [f32; 2], | 1060 | rows: [f32; 2], |
| 1126 | ) -> impl Iterator<Item = (usize, &crate::outline::ParagraphLayout)> { | 1061 | ) -> impl Iterator<Item = (usize, &crate::outline::ParagraphLayout)> { |
| ... | @@ -1141,7 +1076,7 @@ impl crate::outline::OutlineLayout { | ... | @@ -1141,7 +1076,7 @@ impl crate::outline::OutlineLayout { |
| 1141 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black | 1076 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black |
| 1142 | /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. | 1077 | /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. |
| 1143 | /// A paragraph's band spans its outline, or its cell, tinted for the paper. | 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 | &self, | 1080 | &self, |
| 1146 | primitives: &mut Vec<Primitive<'_>>, | 1081 | primitives: &mut Vec<Primitive<'_>>, |
| 1147 | origin: [f32; 2], | 1082 | origin: [f32; 2], |
| ... | @@ -1200,7 +1135,7 @@ impl crate::outline::OutlineLayout { | ... | @@ -1200,7 +1135,7 @@ impl crate::outline::OutlineLayout { |
| 1200 | 1135 | ||
| 1201 | /// One paragraph of this outline, whose origin is `origin`: its text or equation, list | 1136 | /// One paragraph of this outline, whose origin is `origin`: its text or equation, list |
| 1202 | /// markers and tags, those `art` maps drawn with their art. | 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 | &'a self, | 1139 | &'a self, |
| 1205 | index: usize, | 1140 | index: usize, |
| 1206 | paragraph: &'a crate::outline::ParagraphLayout, | 1141 | paragraph: &'a crate::outline::ParagraphLayout, |
| ... | @@ -1291,7 +1226,7 @@ impl crate::outline::OutlineLayout { | ... | @@ -1291,7 +1226,7 @@ impl crate::outline::OutlineLayout { |
| 1291 | 1226 | ||
| 1292 | /// The note tags of this outline's tables, pictures and files, whose origin is `origin`, | 1227 | /// The note tags of this outline's tables, pictures and files, whose origin is `origin`, |
| 1293 | /// those `art` maps drawn with their art. | 1228 | /// those `art` maps drawn with their art. |
| 1294 | pub fn append_block_tag_primitives( | 1229 | pub(crate) fn append_block_tag_primitives( |
| 1295 | &self, | 1230 | &self, |
| 1296 | primitives: &mut Vec<Primitive<'_>>, | 1231 | primitives: &mut Vec<Primitive<'_>>, |
| 1297 | origin: [f32; 2], | 1232 | origin: [f32; 2], |
| ... | @@ -1316,7 +1251,7 @@ impl crate::outline::OutlineLayout { | ... | @@ -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 | &self, | 1255 | &self, |
| 1321 | primitives: &mut Vec<Primitive<'_>>, | 1256 | primitives: &mut Vec<Primitive<'_>>, |
| 1322 | origin: [f32; 2], | 1257 | origin: [f32; 2], |
| ... | @@ -1409,6 +1344,7 @@ mod tests { | ... | @@ -1409,6 +1344,7 @@ mod tests { |
| 1409 | created: Some(1), | 1344 | created: Some(1), |
| 1410 | title: "Header".into(), | 1345 | title: "Header".into(), |
| 1411 | margin_origin: [36.0, 14.4], | 1346 | margin_origin: [36.0, 14.4], |
| 1347 | rtl: false, | ||
| 1412 | color: None, | 1348 | color: None, |
| 1413 | rule_lines: None, | 1349 | rule_lines: None, |
| 1414 | definitions: BTreeMap::new(), | 1350 | definitions: BTreeMap::new(), |
| ... | @@ -1858,6 +1794,7 @@ mod tests { | ... | @@ -1858,6 +1794,7 @@ mod tests { |
| 1858 | created: None, | 1794 | created: None, |
| 1859 | title: "Header".into(), | 1795 | title: "Header".into(), |
| 1860 | margin_origin: [36.0, 14.4], | 1796 | margin_origin: [36.0, 14.4], |
| 1797 | rtl: false, | ||
| 1861 | color: None, | 1798 | color: None, |
| 1862 | rule_lines: None, | 1799 | rule_lines: None, |
| 1863 | definitions: BTreeMap::new(), | 1800 | definitions: BTreeMap::new(), |
| ... | @@ -1934,6 +1871,7 @@ mod tests { | ... | @@ -1934,6 +1871,7 @@ mod tests { |
| 1934 | created: None, | 1871 | created: None, |
| 1935 | title: String::new(), | 1872 | title: String::new(), |
| 1936 | margin_origin: [0.0; 2], | 1873 | margin_origin: [0.0; 2], |
| 1874 | rtl: false, | ||
| 1937 | color: None, | 1875 | color: None, |
| 1938 | rule_lines: None, | 1876 | rule_lines: None, |
| 1939 | definitions: BTreeMap::new(), | 1877 | definitions: BTreeMap::new(), |
| ... | @@ -2020,6 +1958,7 @@ mod tests { | ... | @@ -2020,6 +1958,7 @@ mod tests { |
| 2020 | created: None, | 1958 | created: None, |
| 2021 | title: String::new(), | 1959 | title: String::new(), |
| 2022 | margin_origin: [36.0, 14.0], | 1960 | margin_origin: [36.0, 14.0], |
| 1961 | rtl: false, | ||
| 2023 | color: None, | 1962 | color: None, |
| 2024 | rule_lines: None, | 1963 | rule_lines: None, |
| 2025 | objects: vec![unsupported, PageObject::Outline(editable)], | 1964 | objects: vec![unsupported, PageObject::Outline(editable)], |
| ... | @@ -2108,6 +2047,7 @@ mod tests { | ... | @@ -2108,6 +2047,7 @@ mod tests { |
| 2108 | created: None, | 2047 | created: None, |
| 2109 | title: String::new(), | 2048 | title: String::new(), |
| 2110 | margin_origin: [0.0; 2], | 2049 | margin_origin: [0.0; 2], |
| 2050 | rtl: false, | ||
| 2111 | color: None, | 2051 | color: None, |
| 2112 | rule_lines: None, | 2052 | rule_lines: None, |
| 2113 | definitions: BTreeMap::new(), | 2053 | definitions: BTreeMap::new(), |
| ... | @@ -2294,6 +2234,7 @@ mod tests { | ... | @@ -2294,6 +2234,7 @@ mod tests { |
| 2294 | created: None, | 2234 | created: None, |
| 2295 | title: String::new(), | 2235 | title: String::new(), |
| 2296 | margin_origin: [0.0; 2], | 2236 | margin_origin: [0.0; 2], |
| 2237 | rtl: false, | ||
| 2297 | color: None, | 2238 | color: None, |
| 2298 | rule_lines: None, | 2239 | rule_lines: None, |
| 2299 | definitions: BTreeMap::new(), | 2240 | definitions: BTreeMap::new(), |
| ... | @@ -2478,6 +2419,7 @@ mod tests { | ... | @@ -2478,6 +2419,7 @@ mod tests { |
| 2478 | created: None, | 2419 | created: None, |
| 2479 | title: String::new(), | 2420 | title: String::new(), |
| 2480 | margin_origin: [0.0; 2], | 2421 | margin_origin: [0.0; 2], |
| 2422 | rtl: false, | ||
| 2481 | color: None, | 2423 | color: None, |
| 2482 | rule_lines: None, | 2424 | rule_lines: None, |
| 2483 | definitions: BTreeMap::new(), | 2425 | definitions: BTreeMap::new(), |
| ... | @@ -2592,6 +2534,7 @@ mod tests { | ... | @@ -2592,6 +2534,7 @@ mod tests { |
| 2592 | created: None, | 2534 | created: None, |
| 2593 | title: String::new(), | 2535 | title: String::new(), |
| 2594 | margin_origin: [0.0; 2], | 2536 | margin_origin: [0.0; 2], |
| 2537 | rtl: false, | ||
| 2595 | color: None, | 2538 | color: None, |
| 2596 | rule_lines: None, | 2539 | rule_lines: None, |
| 2597 | definitions: BTreeMap::new(), | 2540 | definitions: BTreeMap::new(), |
| ... | @@ -2689,6 +2632,7 @@ mod tests { | ... | @@ -2689,6 +2632,7 @@ mod tests { |
| 2689 | created: None, | 2632 | created: None, |
| 2690 | title: String::new(), | 2633 | title: String::new(), |
| 2691 | margin_origin: [0.0; 2], | 2634 | margin_origin: [0.0; 2], |
| 2635 | rtl: false, | ||
| 2692 | color: None, | 2636 | color: None, |
| 2693 | rule_lines: None, | 2637 | rule_lines: None, |
| 2694 | definitions: BTreeMap::new(), | 2638 | definitions: BTreeMap::new(), |
| ... | @@ -2763,6 +2707,7 @@ mod tests { | ... | @@ -2763,6 +2707,7 @@ mod tests { |
| 2763 | created: None, | 2707 | created: None, |
| 2764 | title: String::new(), | 2708 | title: String::new(), |
| 2765 | margin_origin: [0.0; 2], | 2709 | margin_origin: [0.0; 2], |
| 2710 | rtl: false, | ||
| 2766 | color: None, | 2711 | color: None, |
| 2767 | rule_lines: None, | 2712 | rule_lines: None, |
| 2768 | definitions: BTreeMap::new(), | 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,11 +410,11 @@ const INLINE_PIXELS: f32 = 1024.0 * 1024.0; |
| 410 | pub(super) struct Background { | 410 | pub(super) struct Background { |
| 411 | svg: &'static str, | 411 | svg: &'static str, |
| 412 | /// Points; OneNote draws a picture without a stored size at its natural size. | 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 | /// For light and for dark paper. | 414 | /// For light and for dark paper. |
| 415 | variants: [Variant; 2], | 415 | variants: [Variant; 2], |
| 416 | /// Whether the first raster is made on the frame's thread rather than awaited. | 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 | /// A raster's device pixels per point, and the dark paper it was recoloured for. | 420 | /// A raster's device pixels per point, and the dark paper it was recoloured for. |
| ... | @@ -430,19 +430,19 @@ struct Variant { | ... | @@ -430,19 +430,19 @@ struct Variant { |
| 430 | 430 | ||
| 431 | impl Background { | 431 | impl Background { |
| 432 | /// The rasters made so far, for light and for dark paper. | 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 | self.variants | 434 | self.variants |
| 435 | .iter() | 435 | .iter() |
| 436 | .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) | 436 | .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | /// Whether both recreate the same template picture at the same size. | 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 | self.svg == other.svg && self.size == other.size | 441 | self.svg == other.svg && self.size == other.size |
| 442 | } | 442 | } |
| 443 | 443 | ||
| 444 | /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker. | 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 | let bytes = picture.bytes.as_deref()?; | 446 | let bytes = picture.bytes.as_deref()?; |
| 447 | let digest: String = Sha256::digest(bytes) | 447 | let digest: String = Sha256::digest(bytes) |
| 448 | .iter() | 448 | .iter() |
| ... | @@ -472,7 +472,7 @@ impl Background { | ... | @@ -472,7 +472,7 @@ impl Background { |
| 472 | } | 472 | } |
| 473 | 473 | ||
| 474 | /// The latest raster for this paper, if one has landed. | 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 | let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?; | 476 | let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?; |
| 477 | Some(image) | 477 | Some(image) |
| 478 | } | 478 | } |
| ... | @@ -480,7 +480,7 @@ impl Background { | ... | @@ -480,7 +480,7 @@ impl Background { |
| 480 | /// Takes a finished raster and asks the worker for one at `scale` device pixels per | 480 | /// Takes a finished raster and asks the worker for one at `scale` device pixels per |
| 481 | /// point when the shown one differs; `waker` hears when it lands. True once the raster | 481 | /// point when the shown one differs; `waker` hears when it lands. True once the raster |
| 482 | /// shown matches. | 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 | let dark = dark(paper); | 484 | let dark = dark(paper); |
| 485 | let [width, height] = self.size.map(|side| side.max(1.0)); | 485 | let [width, height] = self.size.map(|side| side.max(1.0)); |
| 486 | let density = density(scale) | 486 | let density = density(scale) |
| ... | @@ -958,6 +958,7 @@ mod tests { | ... | @@ -958,6 +958,7 @@ mod tests { |
| 958 | created: None, | 958 | created: None, |
| 959 | title: String::new(), | 959 | title: String::new(), |
| 960 | margin_origin: [0.0; 2], | 960 | margin_origin: [0.0; 2], |
| 961 | rtl: false, | ||
| 961 | color: None, | 962 | color: None, |
| 962 | rule_lines: None, | 963 | rule_lines: None, |
| 963 | definitions: BTreeMap::new(), | 964 | definitions: BTreeMap::new(), |
crates/canvas/src/gpu/page/picture.rs+4-4| ... | @@ -30,12 +30,12 @@ pub(super) struct Picture { | ... | @@ -30,12 +30,12 @@ pub(super) struct Picture { |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | impl Picture { | 32 | impl Picture { |
| 33 | /// Reads the size from the picture's header; None when the renderer cannot decode it. | ||
| 34 | /// Whether both show the same stored bytes. | 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 | self.bytes == other.bytes | 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 | pub fn new(bytes: &Arc<[u8]>) -> Option<Self> { | 39 | pub fn new(bytes: &Arc<[u8]>) -> Option<Self> { |
| 40 | Some(Self { | 40 | Some(Self { |
| 41 | native: RasterImage::measure(bytes).ok()?, | 41 | native: RasterImage::measure(bytes).ok()?, |
| ... | @@ -48,11 +48,11 @@ impl Picture { | ... | @@ -48,11 +48,11 @@ impl Picture { |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | /// The latest raster, once one has landed. | 50 | /// The latest raster, once one has landed. |
| 51 | pub fn image(&self) -> Option<&RasterImage> { | 51 | pub(crate) fn image(&self) -> Option<&RasterImage> { |
| 52 | self.shown.as_ref() | 52 | self.shown.as_ref() |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | pub fn failed(&self) -> bool { | 55 | pub(crate) fn failed(&self) -> bool { |
| 56 | self.failed | 56 | self.failed |
| 57 | } | 57 | } |
| 58 | } | 58 | } |
crates/canvas/src/interaction/accessibility.rs+2| ... | @@ -722,6 +722,7 @@ pub(super) mod tests { | ... | @@ -722,6 +722,7 @@ pub(super) mod tests { |
| 722 | title: "Header".into(), | 722 | title: "Header".into(), |
| 723 | created: None, | 723 | created: None, |
| 724 | margin_origin: [36.0, 14.4], | 724 | margin_origin: [36.0, 14.4], |
| 725 | rtl: false, | ||
| 725 | color: None, | 726 | color: None, |
| 726 | rule_lines: None, | 727 | rule_lines: None, |
| 727 | definitions: Default::default(), | 728 | definitions: Default::default(), |
| ... | @@ -816,6 +817,7 @@ pub(super) mod tests { | ... | @@ -816,6 +817,7 @@ pub(super) mod tests { |
| 816 | created: None, | 817 | created: None, |
| 817 | title: String::new(), | 818 | title: String::new(), |
| 818 | margin_origin: [0.0; 2], | 819 | margin_origin: [0.0; 2], |
| 820 | rtl: false, | ||
| 819 | color: None, | 821 | color: None, |
| 820 | rule_lines: None, | 822 | rule_lines: None, |
| 821 | definitions: Default::default(), | 823 | definitions: Default::default(), |
crates/canvas/src/interaction/mod.rs+48-80| ... | @@ -35,7 +35,7 @@ const HANDLE_HEIGHT: f32 = 6.75; | ... | @@ -35,7 +35,7 @@ const HANDLE_HEIGHT: f32 = 6.75; |
| 35 | /// Accessible names of the page date's fields. | 35 | /// Accessible names of the page date's fields. |
| 36 | pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; | 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 | #[derive(Clone, Copy, Debug, PartialEq)] | 40 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 41 | pub enum Cursor { | 41 | pub enum Cursor { |
| ... | @@ -112,7 +112,7 @@ pub struct Correction { | ... | @@ -112,7 +112,7 @@ pub struct Correction { |
| 112 | pub struct Response { | 112 | pub struct Response { |
| 113 | pub changed: bool, | 113 | pub changed: bool, |
| 114 | pub moved: bool, | 114 | pub moved: bool, |
| 115 | pub redraw: bool, | 115 | pub(crate) redraw: bool, |
| 116 | pub request: Option<Request>, | 116 | pub request: Option<Request>, |
| 117 | } | 117 | } |
| 118 | 118 | ||
| ... | @@ -359,7 +359,7 @@ impl PageView { | ... | @@ -359,7 +359,7 @@ impl PageView { |
| 359 | } | 359 | } |
| 360 | 360 | ||
| 361 | /// Shows a page reloaded from storage in place of the edited one, whose editor it returns. | 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 | &mut self, | 363 | &mut self, |
| 364 | mut editor: CanvasEditor, | 364 | mut editor: CanvasEditor, |
| 365 | scene: Option<(PageScene, [f32; 2])>, | 365 | scene: Option<(PageScene, [f32; 2])>, |
| ... | @@ -435,10 +435,16 @@ impl PageView { | ... | @@ -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 | 438 | /// OneNote 2010 opens a page scrolled fully up, however far down its content lies, and |
| 439 | /// lies. | 439 | /// fully left, or right on a right-to-left page. |
| 440 | fn place_opened(&mut self) { | 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 | pub fn modifiers(&self) -> Modifiers { | 450 | pub fn modifiers(&self) -> Modifiers { |
| ... | @@ -480,6 +486,12 @@ impl PageView { | ... | @@ -480,6 +486,12 @@ impl PageView { |
| 480 | self.object_focus.is_none() | 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 | /// The outline being dragged and where it would land. | 495 | /// The outline being dragged and where it would land. |
| 484 | pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { | 496 | pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { |
| 485 | let Drag::Outline { | 497 | let Drag::Outline { |
| ... | @@ -624,7 +636,7 @@ impl PageView { | ... | @@ -624,7 +636,7 @@ impl PageView { |
| 624 | /// Page coordinates of a focused object. | 636 | /// Page coordinates of a focused object. |
| 625 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { | 637 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { |
| 626 | let (scene, offset) = self.scene.as_ref().unwrap(); | 638 | let (scene, offset) = self.scene.as_ref().unwrap(); |
| 627 | let [x0, y0, x1, y1] = match focus { | 639 | let rect = match focus { |
| 628 | ObjectFocus::ReadOnly(index) => scene | 640 | ObjectFocus::ReadOnly(index) => scene |
| 629 | .read_only(Some(&self.editor)) | 641 | .read_only(Some(&self.editor)) |
| 630 | .nth(index) | 642 | .nth(index) |
| ... | @@ -636,12 +648,7 @@ impl PageView { | ... | @@ -636,12 +648,7 @@ impl PageView { |
| 636 | } | 648 | } |
| 637 | ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), | 649 | ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), |
| 638 | }; | 650 | }; |
| 639 | [ | 651 | crate::translated(rect, *offset) |
| 640 | x0 + offset[0], | ||
| 641 | y0 + offset[1], | ||
| 642 | x1 + offset[0], | ||
| 643 | y1 + offset[1], | ||
| 644 | ] | ||
| 645 | } | 652 | } |
| 646 | 653 | ||
| 647 | /// Keeps the view in bounds and restarts the caret blink after a change. | 654 | /// Keeps the view in bounds and restarts the caret blink after a change. |
| ... | @@ -778,10 +785,7 @@ impl PageView { | ... | @@ -778,10 +785,7 @@ impl PageView { |
| 778 | return self.moved(); | 785 | return self.moved(); |
| 779 | } | 786 | } |
| 780 | } | 787 | } |
| 781 | Ok(Response { | 788 | Ok(Response::redraw()) |
| 782 | redraw: true, | ||
| 783 | ..Response::default() | ||
| 784 | }) | ||
| 785 | } | 789 | } |
| 786 | 790 | ||
| 787 | /// Moves the caret by lines until it has gone a view's height, then scrolls as far so | 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,18 +885,16 @@ impl PageView { |
| 881 | .content_bounds(&self.editor) | 885 | .content_bounds(&self.editor) |
| 882 | .map(move |(rect, padded)| { | 886 | .map(move |(rect, padded)| { |
| 883 | ( | 887 | ( |
| 884 | [ | 888 | crate::translated(rect, *offset), |
| 885 | rect[0] + offset[0], | ||
| 886 | rect[1] + offset[1], | ||
| 887 | rect[2] + offset[0], | ||
| 888 | rect[3] + offset[1], | ||
| 889 | ], | ||
| 890 | if padded { pad } else { 0.0 }, | 889 | if padded { pad } else { 0.0 }, |
| 891 | ) | 890 | ) |
| 892 | }) | 891 | }) |
| 893 | }); | 892 | }); |
| 894 | let mut scroll = | 893 | let mut scroll = scroll::Scroll::new( |
| 895 | scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)); | 894 | self.viewport, |
| 895 | editable.map(|rect| (rect, pad)).chain(fixed), | ||
| 896 | self.editor.rtl(), | ||
| 897 | ); | ||
| 896 | if let Some(reach) = self.reach { | 898 | if let Some(reach) = self.reach { |
| 897 | for axis in 0..2 { | 899 | for axis in 0..2 { |
| 898 | scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); | 900 | scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); |
| ... | @@ -989,17 +991,7 @@ impl PageView { | ... | @@ -989,17 +991,7 @@ impl PageView { |
| 989 | preview, | 991 | preview, |
| 990 | self.object_focus, | 992 | self.object_focus, |
| 991 | Paint { | 993 | Paint { |
| 992 | caret: if self.focused | 994 | caret: if self.focused && matches!(self.drag, None | Some(Drag::Text { .. })) { |
| 993 | && !matches!( | ||
| 994 | self.drag, | ||
| 995 | Some( | ||
| 996 | Drag::Outline { .. } | ||
| 997 | | Drag::Resize { .. } | ||
| 998 | | Drag::Column { .. } | ||
| 999 | | Drag::Image { .. } | ||
| 1000 | | Drag::Space { .. } | ||
| 1001 | ) | ||
| 1002 | ) { | ||
| 1003 | self.caret | 995 | self.caret |
| 1004 | } else { | 996 | } else { |
| 1005 | 0.0 | 997 | 0.0 |
| ... | @@ -1069,7 +1061,6 @@ impl PageView { | ... | @@ -1069,7 +1061,6 @@ impl PageView { |
| 1069 | if self.drawing() { | 1061 | if self.drawing() { |
| 1070 | return Cursor::Crosshair; | 1062 | return Cursor::Crosshair; |
| 1071 | } | 1063 | } |
| 1072 | let point = self.viewport.document_point(self.pointer); | ||
| 1073 | if self.drag.is_none() && self.play_button(point).is_some() { | 1064 | if self.drag.is_none() && self.play_button(point).is_some() { |
| 1074 | return Cursor::Pointer; | 1065 | return Cursor::Pointer; |
| 1075 | } | 1066 | } |
| ... | @@ -1111,8 +1102,12 @@ impl PageView { | ... | @@ -1111,8 +1102,12 @@ impl PageView { |
| 1111 | (repaint, hold.map(|hold| now + hold)) | 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 | pub fn resized(&mut self, size: [u32; 2]) -> Result<Response> { | 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 | self.viewport.size = size; | 1111 | self.viewport.size = size; |
| 1117 | if size.contains(&0) { | 1112 | if size.contains(&0) { |
| 1118 | return Ok(Response::default()); | 1113 | return Ok(Response::default()); |
| ... | @@ -1534,7 +1529,7 @@ impl PageView { | ... | @@ -1534,7 +1529,7 @@ impl PageView { |
| 1534 | { | 1529 | { |
| 1535 | return self.edited(); | 1530 | return self.edited(); |
| 1536 | } | 1531 | } |
| 1537 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1532 | if self.edits_wait() { |
| 1538 | return Ok(Response::default()); | 1533 | return Ok(Response::default()); |
| 1539 | } | 1534 | } |
| 1540 | self.editor.format(&mut self.engine, command)?; | 1535 | self.editor.format(&mut self.engine, command)?; |
| ... | @@ -1783,7 +1778,7 @@ impl PageView { | ... | @@ -1783,7 +1778,7 @@ impl PageView { |
| 1783 | 1778 | ||
| 1784 | /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`]. | 1779 | /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`]. |
| 1785 | pub fn insert_equation(&mut self) -> Result<Response> { | 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 | return Ok(Response::default()); | 1782 | return Ok(Response::default()); |
| 1788 | } | 1783 | } |
| 1789 | self.editor.insert_equation(&mut self.engine)?; | 1784 | self.editor.insert_equation(&mut self.engine)?; |
| ... | @@ -1792,7 +1787,7 @@ impl PageView { | ... | @@ -1792,7 +1787,7 @@ impl PageView { |
| 1792 | 1787 | ||
| 1793 | /// Insert, Table: see [`CanvasEditor::insert_table`]. | 1788 | /// Insert, Table: see [`CanvasEditor::insert_table`]. |
| 1794 | pub fn insert_table(&mut self, rows: usize, columns: usize) -> Result<Response> { | 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 | return Ok(Response::default()); | 1791 | return Ok(Response::default()); |
| 1797 | } | 1792 | } |
| 1798 | self.editor.insert_table(&mut self.engine, rows, columns)?; | 1793 | self.editor.insert_table(&mut self.engine, rows, columns)?; |
| ... | @@ -1802,7 +1797,7 @@ impl PageView { | ... | @@ -1802,7 +1797,7 @@ impl PageView { |
| 1802 | /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see | 1797 | /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see |
| 1803 | /// [`CanvasEditor::insert_picture`]. | 1798 | /// [`CanvasEditor::insert_picture`]. |
| 1804 | pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> { | 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 | return Ok(Response::default()); | 1801 | return Ok(Response::default()); |
| 1807 | } | 1802 | } |
| 1808 | let image = crate::editor::picture(bytes, size)?; | 1803 | let image = crate::editor::picture(bytes, size)?; |
| ... | @@ -1818,7 +1813,7 @@ impl PageView { | ... | @@ -1818,7 +1813,7 @@ impl PageView { |
| 1818 | pieces: Vec<Piece>, | 1813 | pieces: Vec<Piece>, |
| 1819 | language: u32, | 1814 | language: u32, |
| 1820 | ) -> Result<(Vec<Awaited>, Response)> { | 1815 | ) -> Result<(Vec<Awaited>, Response)> { |
| 1821 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1816 | if self.edits_wait() { |
| 1822 | return Ok((Vec::new(), Response::default())); | 1817 | return Ok((Vec::new(), Response::default())); |
| 1823 | } | 1818 | } |
| 1824 | let awaited = self | 1819 | let awaited = self |
| ... | @@ -1845,7 +1840,7 @@ impl PageView { | ... | @@ -1845,7 +1840,7 @@ impl PageView { |
| 1845 | 1840 | ||
| 1846 | /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. | 1841 | /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. |
| 1847 | pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { | 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 | return Ok((None, Response::default())); | 1844 | return Ok((None, Response::default())); |
| 1850 | } | 1845 | } |
| 1851 | let id = self.editor.start_recording(&mut self.engine, label)?; | 1846 | let id = self.editor.start_recording(&mut self.engine, label)?; |
| ... | @@ -1879,7 +1874,7 @@ impl PageView { | ... | @@ -1879,7 +1874,7 @@ impl PageView { |
| 1879 | /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon | 1874 | /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon |
| 1880 | /// takes the page's blank one. | 1875 | /// takes the page's blank one. |
| 1881 | pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> { | 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 | return Ok(Response::default()); | 1878 | return Ok(Response::default()); |
| 1884 | } | 1879 | } |
| 1885 | file.preview | 1880 | file.preview |
| ... | @@ -2051,17 +2046,7 @@ impl PageView { | ... | @@ -2051,17 +2046,7 @@ impl PageView { |
| 2051 | return self.edited(); | 2046 | return self.edited(); |
| 2052 | } | 2047 | } |
| 2053 | } | 2048 | } |
| 2054 | if matches!( | 2049 | if !matches!(self.drag, None | Some(Drag::Text { .. })) || self.space { |
| 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 | { | ||
| 2065 | self.drag = None; | 2050 | self.drag = None; |
| 2066 | self.space = false; | 2051 | self.space = false; |
| 2067 | if key == &Key::Named(NamedKey::Escape) { | 2052 | if key == &Key::Named(NamedKey::Escape) { |
| ... | @@ -2270,7 +2255,7 @@ pub enum Hit { | ... | @@ -2270,7 +2255,7 @@ pub enum Hit { |
| 2270 | } | 2255 | } |
| 2271 | 2256 | ||
| 2272 | /// What a document point lands on; `pixel` is document points per device pixel. | 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 | editor: &CanvasEditor, | 2259 | editor: &CanvasEditor, |
| 2275 | scene: Option<&(PageScene, [f32; 2])>, | 2260 | scene: Option<&(PageScene, [f32; 2])>, |
| 2276 | point: [f32; 2], | 2261 | point: [f32; 2], |
| ... | @@ -2574,16 +2559,7 @@ fn page_primitives<'a>( | ... | @@ -2574,16 +2559,7 @@ fn page_primitives<'a>( |
| 2574 | if let Some((scene, _)) = scene { | 2559 | if let Some((scene, _)) = scene { |
| 2575 | let moving = match preview { | 2560 | let moving = match preview { |
| 2576 | Some(PointerFeedback::Image(id, origin, size)) => { | 2561 | Some(PointerFeedback::Image(id, origin, size)) => { |
| 2577 | let [x0, y0, x1, y1] = image_rect(origin, size); | 2562 | Some((id, crate::translated(image_rect(origin, size), offset))) |
| 2578 | Some(( | ||
| 2579 | id, | ||
| 2580 | [ | ||
| 2581 | x0 + offset[0], | ||
| 2582 | y0 + offset[1], | ||
| 2583 | x1 + offset[0], | ||
| 2584 | y1 + offset[1], | ||
| 2585 | ], | ||
| 2586 | )) | ||
| 2587 | } | 2563 | } |
| 2588 | _ => None, | 2564 | _ => None, |
| 2589 | }; | 2565 | }; |
| ... | @@ -2663,13 +2639,10 @@ fn page_primitives<'a>( | ... | @@ -2663,13 +2639,10 @@ fn page_primitives<'a>( |
| 2663 | } | 2639 | } |
| 2664 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { | 2640 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { |
| 2665 | let (scene, offset) = scene.unwrap(); | 2641 | let (scene, offset) = scene.unwrap(); |
| 2666 | let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); | 2642 | let [x0, y0, x1, y1] = crate::translated( |
| 2667 | let [x0, y0, x1, y1] = [ | 2643 | scene.read_only(Some(editor)).nth(index).unwrap().rect(), |
| 2668 | x0 + offset[0], | 2644 | *offset, |
| 2669 | y0 + offset[1], | 2645 | ); |
| 2670 | x1 + offset[0], | ||
| 2671 | y1 + offset[1], | ||
| 2672 | ]; | ||
| 2673 | let border = 2.0 / paint.scale; | 2646 | let border = 2.0 / paint.scale; |
| 2674 | for rect in [ | 2647 | for rect in [ |
| 2675 | [x0, y0, x1, y0 + border], | 2648 | [x0, y0, x1, y0 + border], |
| ... | @@ -2899,12 +2872,7 @@ fn append_outline_chrome( | ... | @@ -2899,12 +2872,7 @@ fn append_outline_chrome( |
| 2899 | let [x, y] = origin; | 2872 | let [x, y] = origin; |
| 2900 | let (bounds, body_top) = outline_chrome(outline, pixel); | 2873 | let (bounds, body_top) = outline_chrome(outline, pixel); |
| 2901 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; | 2874 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; |
| 2902 | let [left, top, right, bottom] = [ | 2875 | let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]); |
| 2903 | bounds[0] + dx, | ||
| 2904 | bounds[1] + dy, | ||
| 2905 | bounds[2] + dx, | ||
| 2906 | bounds[3] + dy, | ||
| 2907 | ]; | ||
| 2908 | let body_top = body_top + dy; | 2876 | let body_top = body_top + dy; |
| 2909 | if outline.title { | 2877 | if outline.title { |
| 2910 | primitives.push(Primitive::RoundedRect { | 2878 | primitives.push(Primitive::RoundedRect { |
crates/canvas/src/interaction/scroll.rs+35-7| ... | @@ -10,25 +10,36 @@ pub struct Scroll { | ... | @@ -10,25 +10,36 @@ pub struct Scroll { |
| 10 | 10 | ||
| 11 | impl Scroll { | 11 | impl Scroll { |
| 12 | /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond | 12 | /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond |
| 13 | /// it left and up. | 13 | /// it up and toward the page's start: left, or right on a right-to-left page. |
| 14 | pub fn new(viewport: Viewport, bounds: impl Iterator<Item = ([f32; 4], f32)>) -> Self { | 14 | pub fn new( |
| 15 | viewport: Viewport, | ||
| 16 | bounds: impl Iterator<Item = ([f32; 4], f32)>, | ||
| 17 | rtl: bool, | ||
| 18 | ) -> Self { | ||
| 15 | let mut min = [0.0_f32; 2]; | 19 | let mut min = [0.0_f32; 2]; |
| 16 | let mut max = [0.0_f32; 2]; | 20 | let mut max = [0.0_f32; 2]; |
| 21 | let mut end = f32::NEG_INFINITY; | ||
| 17 | for (rect, pad) in bounds { | 22 | for (rect, pad) in bounds { |
| 18 | for axis in 0..2 { | 23 | for axis in 0..2 { |
| 19 | min[axis] = min[axis].min(rect[axis] * viewport.scale - pad); | 24 | min[axis] = min[axis].min(rect[axis] * viewport.scale - pad); |
| 20 | max[axis] = max[axis].max(rect[axis + 2]); | 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 | for axis in 0..2 { | 31 | for axis in 0..2 { |
| 25 | max[axis] = | 32 | max[axis] = if rtl && axis == 0 && end.is_finite() { |
| 26 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); | 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 | Self { min, max } | 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 | for axis in 0..2 { | 43 | for axis in 0..2 { |
| 33 | viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); | 44 | viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); |
| 34 | } | 45 | } |
| ... | @@ -49,6 +60,7 @@ mod tests { | ... | @@ -49,6 +60,7 @@ mod tests { |
| 49 | let scroll = Scroll::new( | 60 | let scroll = Scroll::new( |
| 50 | viewport, | 61 | viewport, |
| 51 | [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(), | 62 | [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(), |
| 63 | false, | ||
| 52 | ); | 64 | ); |
| 53 | scroll.clamp(&mut viewport); | 65 | scroll.clamp(&mut viewport); |
| 54 | assert_eq!(viewport.origin, [171.0, 51.0]); | 66 | assert_eq!(viewport.origin, [171.0, 51.0]); |
| ... | @@ -68,7 +80,23 @@ mod tests { | ... | @@ -68,7 +80,23 @@ mod tests { |
| 68 | ([-0.5, -22.35, 613.0, 141.3], 0.0), | 80 | ([-0.5, -22.35, 613.0, 141.3], 0.0), |
| 69 | ([36.0, 86.4, 400.0, 180.0], 11.0), | 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 | assert_eq!(scroll.min, [-1.0, -44.7]); | 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,6 +57,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() |
| 57 | title: "Header".into(), | 57 | title: "Header".into(), |
| 58 | created: Some(1), | 58 | created: Some(1), |
| 59 | margin_origin: [36.0, 14.4], | 59 | margin_origin: [36.0, 14.4], |
| 60 | rtl: false, | ||
| 60 | color: None, | 61 | color: None, |
| 61 | rule_lines: None, | 62 | rule_lines: None, |
| 62 | definitions: Default::default(), | 63 | definitions: Default::default(), |
| ... | @@ -590,6 +591,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { | ... | @@ -590,6 +591,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { |
| 590 | title: String::new(), | 591 | title: String::new(), |
| 591 | created: None, | 592 | created: None, |
| 592 | margin_origin: [0.0; 2], | 593 | margin_origin: [0.0; 2], |
| 594 | rtl: false, | ||
| 593 | color: None, | 595 | color: None, |
| 594 | rule_lines: None, | 596 | rule_lines: None, |
| 595 | definitions: Default::default(), | 597 | definitions: Default::default(), |
| ... | @@ -719,6 +721,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { | ... | @@ -719,6 +721,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { |
| 719 | identity: None, | 721 | identity: None, |
| 720 | created: None, | 722 | created: None, |
| 721 | margin_origin: [36.0, 14.4], | 723 | margin_origin: [36.0, 14.4], |
| 724 | rtl: false, | ||
| 722 | color: None, | 725 | color: None, |
| 723 | rule_lines: None, | 726 | rule_lines: None, |
| 724 | definitions: Default::default(), | 727 | definitions: Default::default(), |
| ... | @@ -1247,6 +1250,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { | ... | @@ -1247,6 +1250,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { |
| 1247 | created: None, | 1250 | created: None, |
| 1248 | title: String::new(), | 1251 | title: String::new(), |
| 1249 | margin_origin: [0.0; 2], | 1252 | margin_origin: [0.0; 2], |
| 1253 | rtl: false, | ||
| 1250 | color: None, | 1254 | color: None, |
| 1251 | rule_lines: None, | 1255 | rule_lines: None, |
| 1252 | definitions: Default::default(), | 1256 | definitions: Default::default(), |
| ... | @@ -1445,6 +1449,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { | ... | @@ -1445,6 +1449,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { |
| 1445 | created: None, | 1449 | created: None, |
| 1446 | title: String::new(), | 1450 | title: String::new(), |
| 1447 | margin_origin: [0.0; 2], | 1451 | margin_origin: [0.0; 2], |
| 1452 | rtl: false, | ||
| 1448 | color: None, | 1453 | color: None, |
| 1449 | rule_lines: None, | 1454 | rule_lines: None, |
| 1450 | definitions: Default::default(), | 1455 | definitions: Default::default(), |
| ... | @@ -1566,6 +1571,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { | ... | @@ -1566,6 +1571,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1566 | identity: None, | 1571 | identity: None, |
| 1567 | created: None, | 1572 | created: None, |
| 1568 | margin_origin: [36.0, 14.4], | 1573 | margin_origin: [36.0, 14.4], |
| 1574 | rtl: false, | ||
| 1569 | color: None, | 1575 | color: None, |
| 1570 | rule_lines: None, | 1576 | rule_lines: None, |
| 1571 | definitions: Default::default(), | 1577 | definitions: Default::default(), |
| ... | @@ -1604,6 +1610,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { | ... | @@ -1604,6 +1610,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { |
| 1604 | identity: None, | 1610 | identity: None, |
| 1605 | created: None, | 1611 | created: None, |
| 1606 | margin_origin: [36.0, 14.4], | 1612 | margin_origin: [36.0, 14.4], |
| 1613 | rtl: false, | ||
| 1607 | color: None, | 1614 | color: None, |
| 1608 | rule_lines: None, | 1615 | rule_lines: None, |
| 1609 | definitions: Default::default(), | 1616 | definitions: Default::default(), |
| ... | @@ -2079,6 +2086,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { | ... | @@ -2079,6 +2086,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { |
| 2079 | identity: None, | 2086 | identity: None, |
| 2080 | created: None, | 2087 | created: None, |
| 2081 | margin_origin: [36.0, 14.4], | 2088 | margin_origin: [36.0, 14.4], |
| 2089 | rtl: false, | ||
| 2082 | color: None, | 2090 | color: None, |
| 2083 | rule_lines: None, | 2091 | rule_lines: None, |
| 2084 | definitions: Default::default(), | 2092 | definitions: Default::default(), |
| ... | @@ -2171,6 +2179,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { | ... | @@ -2171,6 +2179,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { |
| 2171 | identity: None, | 2179 | identity: None, |
| 2172 | created: None, | 2180 | created: None, |
| 2173 | margin_origin: [36.0, 14.4], | 2181 | margin_origin: [36.0, 14.4], |
| 2182 | rtl: false, | ||
| 2174 | color: None, | 2183 | color: None, |
| 2175 | rule_lines: None, | 2184 | rule_lines: None, |
| 2176 | definitions: Default::default(), | 2185 | definitions: Default::default(), |
| ... | @@ -2712,6 +2721,7 @@ fn art_draws_at_once_over_the_rule_lines_and_follows_undo() { | ... | @@ -2712,6 +2721,7 @@ fn art_draws_at_once_over_the_rule_lines_and_follows_undo() { |
| 2712 | title: String::new(), | 2721 | title: String::new(), |
| 2713 | created: None, | 2722 | created: None, |
| 2714 | margin_origin: [36.0, 14.4], | 2723 | margin_origin: [36.0, 14.4], |
| 2724 | rtl: false, | ||
| 2715 | color: None, | 2725 | color: None, |
| 2716 | rule_lines: Some(crate::template::RULE_LINES[2].1), | 2726 | rule_lines: Some(crate::template::RULE_LINES[2].1), |
| 2717 | definitions: Default::default(), | 2727 | definitions: Default::default(), |
| ... | @@ -2832,6 +2842,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { | ... | @@ -2832,6 +2842,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { |
| 2832 | identity: None, | 2842 | identity: None, |
| 2833 | created: None, | 2843 | created: None, |
| 2834 | margin_origin: [36.0, 14.4], | 2844 | margin_origin: [36.0, 14.4], |
| 2845 | rtl: false, | ||
| 2835 | color: None, | 2846 | color: None, |
| 2836 | rule_lines: None, | 2847 | rule_lines: None, |
| 2837 | definitions: Default::default(), | 2848 | definitions: Default::default(), |
| ... | @@ -2959,6 +2970,7 @@ fn misspelled_words_are_marked_and_corrected_in_one_edit() { | ... | @@ -2959,6 +2970,7 @@ fn misspelled_words_are_marked_and_corrected_in_one_edit() { |
| 2959 | identity: None, | 2970 | identity: None, |
| 2960 | created: None, | 2971 | created: None, |
| 2961 | margin_origin: [36.0, 14.4], | 2972 | margin_origin: [36.0, 14.4], |
| 2973 | rtl: false, | ||
| 2962 | color: None, | 2974 | color: None, |
| 2963 | rule_lines: None, | 2975 | rule_lines: None, |
| 2964 | definitions: Default::default(), | 2976 | definitions: Default::default(), |
| ... | @@ -3072,6 +3084,7 @@ fn the_note_playing_scrolls_into_view() { | ... | @@ -3072,6 +3084,7 @@ fn the_note_playing_scrolls_into_view() { |
| 3072 | identity: None, | 3084 | identity: None, |
| 3073 | created: None, | 3085 | created: None, |
| 3074 | margin_origin: [36.0, 14.4], | 3086 | margin_origin: [36.0, 14.4], |
| 3087 | rtl: false, | ||
| 3075 | color: None, | 3088 | color: None, |
| 3076 | rule_lines: None, | 3089 | rule_lines: None, |
| 3077 | definitions: Default::default(), | 3090 | definitions: Default::default(), |
| ... | @@ -3129,6 +3142,7 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { | ... | @@ -3129,6 +3142,7 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { |
| 3129 | identity: None, | 3142 | identity: None, |
| 3130 | created: None, | 3143 | created: None, |
| 3131 | margin_origin: [36.0, 14.4], | 3144 | margin_origin: [36.0, 14.4], |
| 3145 | rtl: false, | ||
| 3132 | color: None, | 3146 | color: None, |
| 3133 | rule_lines: None, | 3147 | rule_lines: None, |
| 3134 | definitions: Default::default(), | 3148 | definitions: Default::default(), |
crates/canvas/src/language.rs+1-1| ... | @@ -135,7 +135,7 @@ pub fn lcid(tag: &str) -> u32 { | ... | @@ -135,7 +135,7 @@ pub fn lcid(tag: &str) -> u32 { |
| 135 | 135 | ||
| 136 | /// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks | 136 | /// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks |
| 137 | /// for it. None for LCIDs outside the table, such as math's. | 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 | LCIDS | 139 | LCIDS |
| 140 | .iter() | 140 | .iter() |
| 141 | .find(|(_, known)| *known == lcid) | 141 | .find(|(_, known)| *known == lcid) |
crates/canvas/src/layout.rs+15-15| ... | @@ -42,11 +42,11 @@ const SUBSCRIPT_DROP: f32 = 0.08; | ... | @@ -42,11 +42,11 @@ const SUBSCRIPT_DROP: f32 = 0.08; |
| 42 | #[derive(Clone, Copy, Debug, Default, PartialEq)] | 42 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 43 | pub struct TextBrush { | 43 | pub struct TextBrush { |
| 44 | /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. | 44 | /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. |
| 45 | pub color: Option<u32>, | 45 | pub(crate) color: Option<u32>, |
| 46 | pub highlight: Option<u32>, | 46 | pub(crate) highlight: Option<u32>, |
| 47 | /// Points the run draws above the line's baseline: positive for a superscript, | 47 | /// Points the run draws above the line's baseline: positive for a superscript, |
| 48 | /// negative for a subscript. Line metrics and hit-testing keep the baseline. | 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 | #[derive(Clone, Debug)] | 52 | #[derive(Clone, Debug)] |
| ... | @@ -72,11 +72,11 @@ pub struct TextLayout { | ... | @@ -72,11 +72,11 @@ pub struct TextLayout { |
| 72 | /// Room kept in a line for something drawn inline, such as an equation, before the text at | 72 | /// Room kept in a line for something drawn inline, such as an equation, before the text at |
| 73 | /// byte `index`; the line grows to its ascent and descent. | 73 | /// byte `index`; the line grows to its ascent and descent. |
| 74 | #[derive(Clone, Copy, Debug, PartialEq)] | 74 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 75 | pub struct InlineSpace { | 75 | pub(crate) struct InlineSpace { |
| 76 | pub index: usize, | 76 | pub(crate) index: usize, |
| 77 | pub width: f32, | 77 | pub(crate) width: f32, |
| 78 | pub ascent: f32, | 78 | pub(crate) ascent: f32, |
| 79 | pub descent: f32, | 79 | pub(crate) descent: f32, |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | @@ -106,8 +106,8 @@ impl fmt::Display for LayoutError { | ... | @@ -106,8 +106,8 @@ impl fmt::Display for LayoutError { |
| 106 | impl std::error::Error for LayoutError {} | 106 | impl std::error::Error for LayoutError {} |
| 107 | 107 | ||
| 108 | /// What a run without a font or size of its own is laid out in. | 108 | /// What a run without a font or size of its own is laid out in. |
| 109 | pub const DEFAULT_FONT: &str = "Arial"; | 109 | pub(crate) const DEFAULT_FONT: &str = "Arial"; |
| 110 | pub const DEFAULT_FONT_SIZE: f32 = 11.0; | 110 | pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; |
| 111 | 111 | ||
| 112 | /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font | 112 | /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font |
| 113 | /// Licence files beside them, by the family each stands in for. | 113 | /// Licence files beside them, by the family each stands in for. |
| ... | @@ -425,7 +425,7 @@ impl TextEngine { | ... | @@ -425,7 +425,7 @@ impl TextEngine { |
| 425 | } | 425 | } |
| 426 | 426 | ||
| 427 | /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. | 427 | /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. |
| 428 | pub fn layout_with( | 428 | pub(crate) fn layout_with( |
| 429 | &mut self, | 429 | &mut self, |
| 430 | paragraph: &Paragraph, | 430 | paragraph: &Paragraph, |
| 431 | width: f32, | 431 | width: f32, |
| ... | @@ -531,7 +531,7 @@ impl TextEngine { | ... | @@ -531,7 +531,7 @@ impl TextEngine { |
| 531 | 531 | ||
| 532 | impl TextLayout { | 532 | impl TextLayout { |
| 533 | /// Process-unique identity for caches of an externally immutable layout. | 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 | self.id | 535 | self.id |
| 536 | } | 536 | } |
| 537 | 537 | ||
| ... | @@ -592,11 +592,11 @@ impl TextLayout { | ... | @@ -592,11 +592,11 @@ impl TextLayout { |
| 592 | last.top + last.height | 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 | Cursor::from_byte_index(&self.shaped, byte, affinity) | 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 | let index = self | 600 | let index = self |
| 601 | .lines | 601 | .lines |
| 602 | .partition_point(|line| line.top + line.height <= y) | 602 | .partition_point(|line| line.top + line.height <= y) |
| ... | @@ -645,7 +645,7 @@ impl TextLayout { | ... | @@ -645,7 +645,7 @@ impl TextLayout { |
| 645 | 645 | ||
| 646 | /// Each line's part of `selection` as a mark under the text runs: its left, its right | 646 | /// Each line's part of `selection` as a mark under the text runs: its left, its right |
| 647 | /// and the line's baseline. | 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 | selection | 649 | selection |
| 650 | .geometry(&self.shaped) | 650 | .geometry(&self.shaped) |
| 651 | .into_iter() | 651 | .into_iter() |
crates/canvas/src/lib.rs+11| ... | @@ -5,6 +5,7 @@ pub mod language; | ... | @@ -5,6 +5,7 @@ pub mod language; |
| 5 | pub mod layout; | 5 | pub mod layout; |
| 6 | pub mod math; | 6 | pub mod math; |
| 7 | pub mod outline; | 7 | pub mod outline; |
| 8 | pub mod reading; | ||
| 8 | pub mod search; | 9 | pub mod search; |
| 9 | pub mod spelling; | 10 | pub mod spelling; |
| 10 | pub mod template; | 11 | pub mod template; |
| ... | @@ -17,6 +18,16 @@ pub mod interaction; | ... | @@ -17,6 +18,16 @@ pub mod interaction; |
| 17 | #[cfg(feature = "pdf")] | 18 | #[cfg(feature = "pdf")] |
| 18 | pub mod print; | 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 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. | 31 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. |
| 21 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { | 32 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { |
| 22 | match affinity { | 33 | match affinity { |
crates/canvas/src/math.rs+4-4| ... | @@ -11,14 +11,14 @@ use onestore::page::text::Paragraph; | ... | @@ -11,14 +11,14 @@ use onestore::page::text::Paragraph; |
| 11 | pub struct MathLayout { | 11 | pub struct MathLayout { |
| 12 | pub size: [f32; 2], | 12 | pub size: [f32; 2], |
| 13 | /// COLORREF for rules and strokes, or `None` for the paper's ink. | 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 | /// Distance from the top to the baseline of the equation's main row. | 15 | /// Distance from the top to the baseline of the equation's main row. |
| 16 | pub baseline: f32, | 16 | pub baseline: f32, |
| 17 | pub items: Vec<MathItem>, | 17 | pub(crate) items: Vec<MathItem>, |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | #[derive(Clone)] | 20 | #[derive(Clone)] |
| 21 | pub enum MathItem { | 21 | pub(crate) enum MathItem { |
| 22 | Text { | 22 | Text { |
| 23 | layout: TextLayout, | 23 | layout: TextLayout, |
| 24 | origin: [f32; 2], | 24 | origin: [f32; 2], |
| ... | @@ -79,7 +79,7 @@ fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem { | ... | @@ -79,7 +79,7 @@ fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem { |
| 79 | layout, | 79 | layout, |
| 80 | origin: [origin[0] + dx, origin[1] + dy], | 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 | MathItem::Stroke { from, to, width } => MathItem::Stroke { | 83 | MathItem::Stroke { from, to, width } => MathItem::Stroke { |
| 84 | from: [from[0] + dx, from[1] + dy], | 84 | from: [from[0] + dx, from[1] + dy], |
| 85 | to: [to[0] + dx, to[1] + dy], | 85 | to: [to[0] + dx, to[1] + dy], |
crates/canvas/src/outline.rs+26-26| ... | @@ -21,7 +21,7 @@ pub struct OutlineLayout { | ... | @@ -21,7 +21,7 @@ pub struct OutlineLayout { |
| 21 | pub paragraphs: Vec<ParagraphLayout>, | 21 | pub paragraphs: Vec<ParagraphLayout>, |
| 22 | pub tables: Vec<TableLayout>, | 22 | pub tables: Vec<TableLayout>, |
| 23 | /// Pictures, files and handwriting that occupy a paragraph of their own. | 23 | /// Pictures, files and handwriting that occupy a paragraph of their own. |
| 24 | pub objects: Vec<ObjectLayout>, | 24 | pub(crate) objects: Vec<ObjectLayout>, |
| 25 | pub size: [f32; 2], | 25 | pub size: [f32; 2], |
| 26 | /// One per root node, so an edit places anew only what it changes and moves what follows. | 26 | /// One per root node, so an edit places anew only what it changes and moves what follows. |
| 27 | blocks: Vec<Block>, | 27 | blocks: Vec<Block>, |
| ... | @@ -49,37 +49,37 @@ struct Block { | ... | @@ -49,37 +49,37 @@ struct Block { |
| 49 | 49 | ||
| 50 | /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. | 50 | /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. |
| 51 | pub(crate) struct Relayout { | 51 | pub(crate) struct Relayout { |
| 52 | pub range: Range<usize>, | 52 | pub(crate) range: Range<usize>, |
| 53 | pub segment: OutlineLayout, | 53 | pub(crate) segment: OutlineLayout, |
| 54 | /// Each following node's new top and flow state, up to the first that stays. | 54 | /// Each following node's new top and flow state, up to the first that stays. |
| 55 | moved: Vec<(f32, (f64, Option<f32>))>, | 55 | moved: Vec<(f32, (f64, Option<f32>))>, |
| 56 | pub size: [f32; 2], | 56 | pub(crate) size: [f32; 2], |
| 57 | widths: [f32; 2], | 57 | widths: [f32; 2], |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | #[derive(Clone)] | 60 | #[derive(Clone)] |
| 61 | pub struct ObjectLayout { | 61 | pub(crate) struct ObjectLayout { |
| 62 | /// The object's identity, which keys a picture's or file icon's decoded image. | 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 | /// Where the picture, file icon, drawing or placeholder draws, outline-local. | 64 | /// Where the picture, file icon, drawing or placeholder draws, outline-local. |
| 65 | pub rect: [f32; 4], | 65 | pub(crate) rect: [f32; 4], |
| 66 | pub kind: ObjectKind, | 66 | pub(crate) kind: ObjectKind, |
| 67 | /// Outline-local bottom of the whole object, label included. | 67 | /// Outline-local bottom of the whole object, label included. |
| 68 | pub bottom: f32, | 68 | pub(crate) bottom: f32, |
| 69 | pub tags: Option<BlockTags>, | 69 | pub(crate) tags: Option<BlockTags>, |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | /// The note tags of a table, picture or file, which OneNote centres on it in the tag column. | 72 | /// The note tags of a table, picture or file, which OneNote centres on it in the tag column. |
| 73 | #[derive(Clone)] | 73 | #[derive(Clone)] |
| 74 | pub struct BlockTags { | 74 | pub(crate) struct BlockTags { |
| 75 | /// The paragraph holding the block. | 75 | /// The paragraph holding the block. |
| 76 | pub paragraph: ExGuid, | 76 | pub(crate) paragraph: ExGuid, |
| 77 | /// Its siblings' group, whose list markers the tags clear. | 77 | /// Its siblings' group, whose list markers the tags clear. |
| 78 | parent: Option<ExGuid>, | 78 | parent: Option<ExGuid>, |
| 79 | /// The block's left edge, outline-local. | 79 | /// The block's left edge, outline-local. |
| 80 | x: f32, | 80 | x: f32, |
| 81 | /// Origins are outline-local. | 81 | /// Origins are outline-local. |
| 82 | pub tags: Vec<ParagraphTag>, | 82 | pub(crate) tags: Vec<ParagraphTag>, |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | impl BlockTags { | 85 | impl BlockTags { |
| ... | @@ -130,7 +130,7 @@ impl BlockTags { | ... | @@ -130,7 +130,7 @@ impl BlockTags { |
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | #[derive(Clone)] | 132 | #[derive(Clone)] |
| 133 | pub enum ObjectKind { | 133 | pub(crate) enum ObjectKind { |
| 134 | Picture, | 134 | Picture, |
| 135 | /// A file's icon with its name centered below. | 135 | /// A file's icon with its name centered below. |
| 136 | File(ParagraphLayout), | 136 | File(ParagraphLayout), |
| ... | @@ -141,7 +141,7 @@ pub enum ObjectKind { | ... | @@ -141,7 +141,7 @@ pub enum ObjectKind { |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | impl ObjectLayout { | 143 | impl ObjectLayout { |
| 144 | pub fn label(&self) -> Option<&ParagraphLayout> { | 144 | pub(crate) fn label(&self) -> Option<&ParagraphLayout> { |
| 145 | match &self.kind { | 145 | match &self.kind { |
| 146 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), | 146 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), |
| 147 | ObjectKind::Picture | ObjectKind::Ink(_) => None, | 147 | ObjectKind::Picture | ObjectKind::Ink(_) => None, |
| ... | @@ -243,7 +243,7 @@ pub struct TableLayout { | ... | @@ -243,7 +243,7 @@ pub struct TableLayout { |
| 243 | /// Cells are in paragraph order with disjoint visible ranges. | 243 | /// Cells are in paragraph order with disjoint visible ranges. |
| 244 | pub cells: Vec<CellLayout>, | 244 | pub cells: Vec<CellLayout>, |
| 245 | pub borders: bool, | 245 | pub borders: bool, |
| 246 | pub tags: Option<BlockTags>, | 246 | pub(crate) tags: Option<BlockTags>, |
| 247 | } | 247 | } |
| 248 | 248 | ||
| 249 | #[derive(Clone)] | 249 | #[derive(Clone)] |
| ... | @@ -251,12 +251,12 @@ pub struct CellLayout { | ... | @@ -251,12 +251,12 @@ pub struct CellLayout { |
| 251 | pub id: ExGuid, | 251 | pub id: ExGuid, |
| 252 | pub rect: [f32; 4], | 252 | pub rect: [f32; 4], |
| 253 | /// Visible paragraph indices, including paragraphs in nested tables. | 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 | impl CellLayout { | 257 | impl CellLayout { |
| 258 | /// Native ink gutters extend beyond the cell borders. | 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 | self.rect[0] - 2.7, | 261 | self.rect[0] - 2.7, |
| 262 | self.rect[1], | 262 | self.rect[1], |
| ... | @@ -265,7 +265,7 @@ impl CellLayout { | ... | @@ -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 | let [left, top, right, bottom] = self.text_bounds().map(f64::from); | 269 | let [left, top, right, bottom] = self.text_bounds().map(f64::from); |
| 270 | let rect = parley::BoundingBox::new( | 270 | let rect = parley::BoundingBox::new( |
| 271 | rect.x0.max(left), | 271 | rect.x0.max(left), |
| ... | @@ -296,7 +296,7 @@ pub struct ParagraphLayout { | ... | @@ -296,7 +296,7 @@ pub struct ParagraphLayout { |
| 296 | pub math: Vec<crate::math::MathLayout>, | 296 | pub math: Vec<crate::math::MathLayout>, |
| 297 | /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its | 297 | /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its |
| 298 | /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. | 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 | /// How the page draws a note tag. | 302 | /// How the page draws a note tag. |
| ... | @@ -318,13 +318,13 @@ impl TagIcon { | ... | @@ -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 | matches!(self, Self::Symbol { shape, .. } if checkable(shape)) | 322 | matches!(self, Self::Symbol { shape, .. } if checkable(shape)) |
| 323 | } | 323 | } |
| 324 | } | 324 | } |
| 325 | 325 | ||
| 326 | /// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. | 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 | matches!( | 328 | matches!( |
| 329 | shape, | 329 | shape, |
| 330 | 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 | 330 | 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 |
| ... | @@ -498,9 +498,9 @@ pub struct ParagraphTag { | ... | @@ -498,9 +498,9 @@ pub struct ParagraphTag { |
| 498 | 498 | ||
| 499 | impl ParagraphTag { | 499 | impl ParagraphTag { |
| 500 | /// The icon's side beside 10 to 17.5 pt text. | 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 | /// How far a 12 pt icon starts left of its text when no sibling has a list marker. | 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 | /// Space between a tag and the leftmost list marker among its paragraph's siblings. | 504 | /// Space between a tag and the leftmost list marker among its paragraph's siblings. |
| 505 | const MARKER_GAP: f32 = 0.9; | 505 | const MARKER_GAP: f32 = 0.9; |
| 506 | 506 | ||
| ... | @@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>( | ... | @@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>( |
| 1258 | impl OutlineLayout { | 1258 | impl OutlineLayout { |
| 1259 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each | 1259 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each |
| 1260 | /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. | 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 | -self | 1262 | -self |
| 1263 | .paragraphs | 1263 | .paragraphs |
| 1264 | .iter() | 1264 | .iter() |
| ... | @@ -1296,7 +1296,7 @@ impl OutlineLayout { | ... | @@ -1296,7 +1296,7 @@ impl OutlineLayout { |
| 1296 | } | 1296 | } |
| 1297 | 1297 | ||
| 1298 | /// Innermost table cell containing a visible paragraph index. | 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 | self.tables.iter().rev().find_map(|table| { | 1300 | self.tables.iter().rev().find_map(|table| { |
| 1301 | let cell = table | 1301 | let cell = table |
| 1302 | .cells | 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,7 +2,7 @@ |
| 2 | //! case and diacritics; a page matches when all its words appear in its title or text, and | 2 | //! case and diacritics; a page matches when all its words appear in its title or text, and |
| 3 | //! pages whose titles hold every word come first, then the most recently modified. | 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 | use crate::editor::{CanvasEditor, Selection}; | 6 | use crate::editor::{CanvasEditor, Selection}; |
| 7 | use icu_normalizer::DecomposingNormalizerBorrowed; | 7 | use icu_normalizer::DecomposingNormalizerBorrowed; |
| 8 | use onestore::ExGuid; | 8 | use onestore::ExGuid; |
| ... | @@ -67,7 +67,7 @@ impl Query { | ... | @@ -67,7 +67,7 @@ impl Query { |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | /// Where the query's words start words of `text`, as byte ranges of it. | 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 | let (folded, source) = fold_mapped(text); | 71 | let (folded, source) = fold_mapped(text); |
| 72 | self.hits(&folded) | 72 | self.hits(&folded) |
| 73 | .into_iter() | 73 | .into_iter() |
| ... | @@ -180,25 +180,13 @@ pub fn shown(paragraph: &Paragraph) -> String { | ... | @@ -180,25 +180,13 @@ pub fn shown(paragraph: &Paragraph) -> String { |
| 180 | /// Calls `visit` with each text paragraph of the page's outlines, in page order, tables | 180 | /// Calls `visit` with each text paragraph of the page's outlines, in page order, tables |
| 181 | /// cell by cell. | 181 | /// cell by cell. |
| 182 | fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) { | 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 | for object in &page.objects { | 183 | for object in &page.objects { |
| 200 | if let PageObject::Outline(outline) = object { | 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,32 +194,19 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, |
| 206 | /// The text OneNote recognised in each of the page's pictures, or printed on a printout's | 194 | /// The text OneNote recognised in each of the page's pictures, or printed on a printout's |
| 207 | /// pages, in page order. | 195 | /// pages, in page order. |
| 208 | fn picture_text(page: &Page) -> Vec<&str> { | 196 | fn picture_text(page: &Page) -> Vec<&str> { |
| 209 | fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) { | 197 | let pictures = page.objects.iter().flat_map(|object| match object { |
| 210 | for paragraph in paragraphs { | 198 | PageObject::Outline(outline) => descendants(&outline.paragraphs, None) |
| 211 | match &paragraph.content { | 199 | .filter_map(|(_, _, node)| match &node.content { |
| 212 | ParagraphContent::Image(image) => { | 200 | ParagraphContent::Image(image) => Some(image), |
| 213 | out.extend(image.text.as_ref().map(|text| text.text.as_str())) | 201 | _ => None, |
| 214 | } | 202 | }) |
| 215 | ParagraphContent::Table(table) => { | 203 | .collect(), |
| 216 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | 204 | PageObject::Image(image) => vec![image], |
| 217 | walk(&cell.paragraphs, out); | 205 | _ => Vec::new(), |
| 218 | } | 206 | }); |
| 219 | } | 207 | pictures |
| 220 | _ => {} | 208 | .filter_map(|image| Some(image.text.as_ref()?.text.as_str())) |
| 221 | } | 209 | .collect() |
| 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 | ||
| 235 | } | 210 | } |
| 236 | 211 | ||
| 237 | /// A page's text outside its title, a paragraph to a line, then the text in its pictures, | 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,10 +60,10 @@ pub fn pick(language: u32, available: &[String]) -> Option<&str> { |
| 60 | 60 | ||
| 61 | /// A marked word: misspelled, or repeating the word before it. | 61 | /// A marked word: misspelled, or repeating the word before it. |
| 62 | #[derive(Clone, Debug, PartialEq, Eq)] | 62 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 63 | pub struct Mark { | 63 | pub(crate) struct Mark { |
| 64 | /// Bytes of the paragraph's text. | 64 | /// Bytes of the paragraph's text. |
| 65 | pub range: Range<usize>, | 65 | pub(crate) range: Range<usize>, |
| 66 | pub repeated: bool, | 66 | pub(crate) repeated: bool, |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | /// A word of a paragraph: its bytes, its language, and whether its spelling is checked. | 69 | /// A word of a paragraph: its bytes, its language, and whether its spelling is checked. |
| ... | @@ -352,7 +352,7 @@ impl Spelling { | ... | @@ -352,7 +352,7 @@ impl Spelling { |
| 352 | 352 | ||
| 353 | /// The marks on `paragraph` once it is checked, less accepted words; until then none, | 353 | /// The marks on `paragraph` once it is checked, less accepted words; until then none, |
| 354 | /// and it is checked. | 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 | let key = key(paragraph); | 356 | let key = key(paragraph); |
| 357 | let mut state = self.shared.state.lock().unwrap(); | 357 | let mut state = self.shared.state.lock().unwrap(); |
| 358 | if let Some(marks) = state.marks(key, paragraph) { | 358 | if let Some(marks) = state.marks(key, paragraph) { |
| ... | @@ -366,7 +366,7 @@ impl Spelling { | ... | @@ -366,7 +366,7 @@ impl Spelling { |
| 366 | 366 | ||
| 367 | /// The marks on `paragraph`, checked on this thread where not yet checked, as the | 367 | /// The marks on `paragraph`, checked on this thread where not yet checked, as the |
| 368 | /// Spelling pane walks the page. | 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 | let key = key(paragraph); | 370 | let key = key(paragraph); |
| 371 | if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) { | 371 | if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) { |
| 372 | return marks; | 372 | return marks; |
| ... | @@ -378,7 +378,7 @@ impl Spelling { | ... | @@ -378,7 +378,7 @@ impl Spelling { |
| 378 | } | 378 | } |
| 379 | 379 | ||
| 380 | /// Corrections for `word` in `language`, best first. | 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 | self.shared | 382 | self.shared |
| 383 | .dictionary | 383 | .dictionary |
| 384 | .suggest(word, language.unwrap_or(crate::language::EN_US)) | 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,10 +7,10 @@ publish = false |
| 7 | [features] | 7 | [features] |
| 8 | default = ["wgpu"] | 8 | default = ["wgpu"] |
| 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through | 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 | 10 | # OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its |
| 11 | # Windows 7 has no Direct3D 12. | 11 | # maker picks one, as Windows 7 has no Direct3D 12. |
| 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | 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 | # Writes layers as PDF pages, text as real text in subset fonts. | 14 | # Writes layers as PDF pages, text as real text in subset fonts. |
| 15 | pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] | 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,6 +28,16 @@ subsetter = { version = "0.2.6", default-features = false, optional = true } |
| 28 | swash = { version = "0.2.10", optional = true } | 28 | swash = { version = "0.2.10", optional = true } |
| 29 | wgpu = { workspace = true, optional = true } | 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 | [dev-dependencies] | 41 | [dev-dependencies] |
| 32 | pollster = "0.4" | 42 | pollster = "0.4" |
| 33 | png = "0.18" | 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,25 +432,11 @@ pub fn caret_blink(since: Duration) -> (f32, Option<Duration>) { |
| 432 | /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright | 432 | /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright |
| 433 | /// as AppKit's layers do, which blend in sRGB rather than linear light. | 433 | /// as AppKit's layers do, which blend in sRGB rather than linear light. |
| 434 | pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] { | 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 | let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; | 435 | let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; |
| 450 | let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); | 436 | let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); |
| 451 | let shown: [f32; 3] = std::array::from_fn(|channel| { | 437 | let shown: [f32; 3] = std::array::from_fn(|channel| { |
| 452 | let [under, over] = [under[channel], over[channel]].map(encode); | 438 | let [under, over] = [under[channel], over[channel]].map(crate::encode); |
| 453 | decode(under + opacity * (over - under)) | 439 | crate::linear(under + opacity * (over - under)) |
| 454 | }); | 440 | }); |
| 455 | let span = luminance(over) - luminance(under); | 441 | let span = luminance(over) - luminance(under); |
| 456 | let alpha = if span.abs() < 1e-4 { | 442 | let alpha = if span.abs() < 1e-4 { |
crates/draw/src/lib.rs+20-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | //! What the page and the interface share: painting layers of glyphs, icons, paths, | 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 | 2 | //! images, rounded rectangles and pen strokes with wgpu, Direct3D 11 or OpenGL, each layer |
| 3 | //! own transform and clip, and editing text within a laid-out line. | 3 | //! with its own transform and clip, and editing text within a laid-out line. |
| 4 | 4 | ||
| 5 | pub mod edit; | 5 | pub mod edit; |
| 6 | #[cfg(feature = "render")] | 6 | #[cfg(feature = "render")] |
| ... | @@ -8,3 +8,21 @@ mod render; | ... | @@ -8,3 +8,21 @@ mod render; |
| 8 | 8 | ||
| 9 | #[cfg(feature = "render")] | 9 | #[cfg(feature = "render")] |
| 10 | pub use render::*; | 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,4 +1,6 @@ |
| 1 | #[cfg(all(feature = "wgpu", windows))] | 1 | #[cfg(all(feature = "wgpu", windows))] |
| 2 | mod d3d11; | ||
| 3 | #[cfg(all(feature = "wgpu", windows))] | ||
| 2 | mod dual; | 4 | mod dual; |
| 3 | #[cfg(any(windows, not(feature = "wgpu")))] | 5 | #[cfg(any(windows, not(feature = "wgpu")))] |
| 4 | mod gl; | 6 | mod gl; |
| ... | @@ -19,8 +21,6 @@ use gl as backend; | ... | @@ -19,8 +21,6 @@ use gl as backend; |
| 19 | use webgpu as backend; | 21 | use webgpu as backend; |
| 20 | 22 | ||
| 21 | pub use backend::Target; | 23 | pub use backend::Target; |
| 22 | #[cfg(all(feature = "wgpu", windows))] | ||
| 23 | pub use gl::Target as GlTarget; | ||
| 24 | pub use icon::{Palette, picture_icon}; | 24 | pub use icon::{Palette, picture_icon}; |
| 25 | #[cfg(feature = "pdf")] | 25 | #[cfg(feature = "pdf")] |
| 26 | pub use pdf::{Sheet, pdf}; | 26 | pub use pdf::{Sheet, pdf}; |
| ... | @@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob; | ... | @@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob; |
| 34 | use parley::fontique::Blob; | 34 | use parley::fontique::Blob; |
| 35 | use std::{ | 35 | use std::{ |
| 36 | collections::{HashMap, HashSet}, | 36 | collections::{HashMap, HashSet}, |
| 37 | ffi::CStr, | ||
| 38 | mem::offset_of, | ||
| 37 | ops::Range, | 39 | ops::Range, |
| 38 | }; | 40 | }; |
| 39 | use swash::{ | 41 | use swash::{ |
| ... | @@ -171,14 +173,25 @@ impl RasterImage { | ... | @@ -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 | struct Frame<'a> { | 178 | struct Frame<'a> { |
| 176 | vertices: &'a [Vertex], | 179 | vertices: &'a [Vertex], |
| 180 | groups: &'a [Vec<Batch>], | ||
| 177 | batches: &'a [Batch], | 181 | batches: &'a [Batch], |
| 178 | groups: &'a [Group], | ||
| 179 | images: &'a HashMap<u64, CachedImage>, | 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 | struct CachedImage { | 195 | struct CachedImage { |
| 183 | texture: backend::Image, | 196 | texture: backend::Image, |
| 184 | bytes: u64, | 197 | bytes: u64, |
| ... | @@ -192,24 +205,58 @@ struct Batch { | ... | @@ -192,24 +205,58 @@ struct Batch { |
| 192 | scissor: [u32; 4], | 205 | scissor: [u32; 4], |
| 193 | } | 206 | } |
| 194 | 207 | ||
| 195 | /// Batches that paint offscreen as one, then onto the target by `composite`'s vertices. | 208 | /// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints |
| 196 | struct Group { | 209 | /// a picture. |
| 197 | batches: Range<usize>, | ||
| 198 | composite: Range<u32>, | ||
| 199 | } | ||
| 200 | |||
| 201 | /// How a batch meets what lies beneath it. | ||
| 202 | #[derive(Clone, Copy, PartialEq)] | 210 | #[derive(Clone, Copy, PartialEq)] |
| 203 | enum Blend { | 211 | enum Blend { |
| 204 | Over, | 212 | Over, |
| 205 | /// A premultiplied picture over it. | 213 | /// A premultiplied picture over it. |
| 206 | Image(u64), | 214 | Image(u64), |
| 215 | /// That group's offscreen picture over it. | ||
| 216 | Group(usize), | ||
| 207 | /// Clearing it by the batch's coverage. | 217 | /// Clearing it by the batch's coverage. |
| 208 | Erase, | 218 | Erase, |
| 209 | /// Multiplying it by the batch's colour where covered. | 219 | /// Multiplying it by the batch's colour where covered. |
| 210 | Multiply, | 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 | #[repr(C)] | 260 | #[repr(C)] |
| 214 | #[derive(Clone, Copy, Pod, Zeroable)] | 261 | #[derive(Clone, Copy, Pod, Zeroable)] |
| 215 | struct Vertex { | 262 | struct Vertex { |
| ... | @@ -228,6 +275,19 @@ struct Vertex { | ... | @@ -228,6 +275,19 @@ struct Vertex { |
| 228 | blur: f32, | 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 | #[derive(Hash, PartialEq, Eq)] | 291 | #[derive(Hash, PartialEq, Eq)] |
| 232 | enum AtlasKey { | 292 | enum AtlasKey { |
| 233 | Text { | 293 | Text { |
| ... | @@ -313,6 +373,18 @@ impl Motion { | ... | @@ -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 | /// A layer's transform onto the target. | 388 | /// A layer's transform onto the target. |
| 317 | #[derive(Clone, Copy)] | 389 | #[derive(Clone, Copy)] |
| 318 | struct Space { | 390 | struct Space { |
| ... | @@ -324,13 +396,18 @@ struct Space { | ... | @@ -324,13 +396,18 @@ struct Space { |
| 324 | } | 396 | } |
| 325 | 397 | ||
| 326 | impl Space { | 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 | fn visible_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { | 409 | fn visible_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { |
| 328 | let rect = [ | 410 | let rect = self.rect(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 | ]; | ||
| 334 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { | 411 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { |
| 335 | return Err(RenderError::InvalidPrimitive); | 412 | return Err(RenderError::InvalidPrimitive); |
| 336 | } | 413 | } |
| ... | @@ -508,7 +585,7 @@ pub struct Renderer { | ... | @@ -508,7 +585,7 @@ pub struct Renderer { |
| 508 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, | 585 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, |
| 509 | images: HashMap<u64, CachedImage>, | 586 | images: HashMap<u64, CachedImage>, |
| 510 | batches: Vec<Batch>, | 587 | batches: Vec<Batch>, |
| 511 | groups: Vec<Group>, | 588 | groups: Vec<Vec<Batch>>, |
| 512 | /// Batches before this one take no more primitives. | 589 | /// Batches before this one take no more primitives. |
| 513 | barrier: usize, | 590 | barrier: usize, |
| 514 | scaler: ScaleContext, | 591 | scaler: ScaleContext, |
| ... | @@ -516,10 +593,36 @@ pub struct Renderer { | ... | @@ -516,10 +593,36 @@ pub struct Renderer { |
| 516 | row_height: u32, | 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 | impl Renderer { | 622 | impl Renderer { |
| 520 | fn with_gpu(gpu: backend::Gpu) -> Self { | 623 | fn with_gpu(gpu: impl Into<backend::Gpu>) -> Self { |
| 521 | let renderer = Self { | 624 | let mut renderer = Self { |
| 522 | gpu, | 625 | gpu: gpu.into(), |
| 523 | vertices: Vec::new(), | 626 | vertices: Vec::new(), |
| 524 | glyphs: HashMap::new(), | 627 | glyphs: HashMap::new(), |
| 525 | images: HashMap::new(), | 628 | images: HashMap::new(), |
| ... | @@ -527,10 +630,10 @@ impl Renderer { | ... | @@ -527,10 +630,10 @@ impl Renderer { |
| 527 | groups: Vec::new(), | 630 | groups: Vec::new(), |
| 528 | barrier: 0, | 631 | barrier: 0, |
| 529 | scaler: ScaleContext::with_max_entries(32), | 632 | scaler: ScaleContext::with_max_entries(32), |
| 530 | pen: [1, 0], | 633 | pen: [0; 2], |
| 531 | row_height: 1, | 634 | row_height: 0, |
| 532 | }; | 635 | }; |
| 533 | renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | 636 | renderer.clear_glyph_cache(); |
| 534 | renderer | 637 | renderer |
| 535 | } | 638 | } |
| 536 | 639 | ||
| ... | @@ -539,23 +642,12 @@ impl Renderer { | ... | @@ -539,23 +642,12 @@ impl Renderer { |
| 539 | self.gpu.max_texture_dimension() | 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 | fn glyph_limit(&self) -> usize { | 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 | fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] { | 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 | glyph.x as f32 / side, | 652 | glyph.x as f32 / side, |
| 561 | glyph.y as f32 / side, | 653 | glyph.y as f32 / side, |
| ... | @@ -564,13 +656,15 @@ impl Renderer { | ... | @@ -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 | fn white(&self) -> [f32; 4] { | 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 | pub fn clear_glyph_cache(&mut self) { | 665 | pub fn clear_glyph_cache(&mut self) { |
| 573 | self.glyphs.clear(); | 666 | self.glyphs.clear(); |
| 667 | self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); | ||
| 574 | self.pen = [1, 0]; | 668 | self.pen = [1, 0]; |
| 575 | self.row_height = 1; | 669 | self.row_height = 1; |
| 576 | } | 670 | } |
| ... | @@ -593,12 +687,12 @@ impl Renderer { | ... | @@ -593,12 +687,12 @@ impl Renderer { |
| 593 | images: self.images.len(), | 687 | images: self.images.len(), |
| 594 | image_bytes, | 688 | image_bytes, |
| 595 | vertices: self.vertices.len(), | 689 | vertices: self.vertices.len(), |
| 596 | batches: self.batches.len(), | 690 | batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::<usize>(), |
| 597 | vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(), | 691 | vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(), |
| 598 | batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(), | 692 | batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(), |
| 599 | glyph_capacity: self.glyphs.capacity(), | 693 | glyph_capacity: self.glyphs.capacity(), |
| 600 | image_capacity: self.images.capacity(), | 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 | vertex_buffer_bytes: VERTEX_BUFFER_BYTES, | 696 | vertex_buffer_bytes: VERTEX_BUFFER_BYTES, |
| 603 | within_budget: self.glyphs.len() <= self.glyph_limit() | 697 | within_budget: self.glyphs.len() <= self.glyph_limit() |
| 604 | && self.images.len() <= MAX_IMAGES | 698 | && self.images.len() <= MAX_IMAGES |
| ... | @@ -633,16 +727,9 @@ impl Renderer { | ... | @@ -633,16 +727,9 @@ impl Renderer { |
| 633 | let mut active_images = HashSet::new(); | 727 | let mut active_images = HashSet::new(); |
| 634 | let mut image_bytes = 0; | 728 | let mut image_bytes = 0; |
| 635 | for layer in layers { | 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 | for primitive in layer.primitives { | 730 | for primitive in layer.primitives { |
| 644 | if let Primitive::Image { image, rect } = primitive | 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 | && active_images.insert(image.id()) | 733 | && active_images.insert(image.id()) |
| 647 | { | 734 | { |
| 648 | image_bytes += image.pixels().len() as u64; | 735 | image_bytes += image.pixels().len() as u64; |
| ... | @@ -668,15 +755,16 @@ impl Renderer { | ... | @@ -668,15 +755,16 @@ impl Renderer { |
| 668 | }; | 755 | }; |
| 669 | // Once cleared, the atlas holds only this frame's entries; past half full, the | 756 | // Once cleared, the atlas holds only this frame's entries; past half full, the |
| 670 | // next frames would clear it again and again. | 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 | if !crowded { | 759 | if !crowded { |
| 673 | break; | 760 | break; |
| 674 | } | 761 | } |
| 675 | if !cleared { | 762 | if !cleared { |
| 676 | self.clear_glyph_cache(); | 763 | self.clear_glyph_cache(); |
| 677 | cleared = true; | 764 | cleared = true; |
| 678 | } else if self.atlas_side() * 2 <= limit { | 765 | } else if self.gpu.atlas_side() * 2 <= limit { |
| 679 | self.grow_atlas(); | 766 | self.gpu.new_atlas(self.gpu.atlas_side() * 2); |
| 767 | self.clear_glyph_cache(); | ||
| 680 | } else if full { | 768 | } else if full { |
| 681 | return Err(RenderError::AtlasFull); | 769 | return Err(RenderError::AtlasFull); |
| 682 | } else { | 770 | } else { |
| ... | @@ -685,8 +773,8 @@ impl Renderer { | ... | @@ -685,8 +773,8 @@ impl Renderer { |
| 685 | } | 773 | } |
| 686 | let frame = Frame { | 774 | let frame = Frame { |
| 687 | vertices: &self.vertices, | 775 | vertices: &self.vertices, |
| 688 | batches: &self.batches, | ||
| 689 | groups: &self.groups, | 776 | groups: &self.groups, |
| 777 | batches: &self.batches, | ||
| 690 | images: &self.images, | 778 | images: &self.images, |
| 691 | }; | 779 | }; |
| 692 | self.gpu.submit(&frame, target, size, clear); | 780 | self.gpu.submit(&frame, target, size, clear); |
| ... | @@ -713,13 +801,7 @@ impl Renderer { | ... | @@ -713,13 +801,7 @@ impl Renderer { |
| 713 | self.barrier = self.batches.len(); | 801 | self.barrier = self.batches.len(); |
| 714 | group = motion.map(|motion| (motion, self.batches.len())); | 802 | group = motion.map(|motion| (motion, self.batches.len())); |
| 715 | } | 803 | } |
| 716 | let space = Space { | 804 | let space = layer.space(size); |
| 717 | size, | ||
| 718 | scale: layer.scale, | ||
| 719 | origin: layer.origin, | ||
| 720 | backdrop: layer.backdrop, | ||
| 721 | round: layer.round, | ||
| 722 | }; | ||
| 723 | let bounds = match layer.clip { | 805 | let bounds = match layer.clip { |
| 724 | Some(clip) => space.scissor(clip), | 806 | Some(clip) => space.scissor(clip), |
| 725 | None => [0, 0, size[0], size[1]], | 807 | None => [0, 0, size[0], size[1]], |
| ... | @@ -737,12 +819,11 @@ impl Renderer { | ... | @@ -737,12 +819,11 @@ impl Renderer { |
| 737 | Ok(()) | 819 | Ok(()) |
| 738 | } | 820 | } |
| 739 | 821 | ||
| 740 | /// Makes the batches from `start` a group appearing by `motion`: the vertices that | 822 | /// Makes the batches from `start` a group appearing by `motion`, leaving in their place |
| 741 | /// paint its offscreen picture over the target, in strips so the turn stays in | 823 | /// the batch that paints its offscreen picture over the target, in strips so the turn |
| 742 | /// perspective across the picture. | 824 | /// stays in perspective across the picture. |
| 743 | fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { | 825 | fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { |
| 744 | let batches = start..self.batches.len(); | 826 | if start == self.batches.len() { |
| 745 | if batches.is_empty() { | ||
| 746 | return Ok(()); | 827 | return Ok(()); |
| 747 | } | 828 | } |
| 748 | let [width, height] = size.map(|side| side as f32); | 829 | let [width, height] = size.map(|side| side as f32); |
| ... | @@ -771,12 +852,7 @@ impl Renderer { | ... | @@ -771,12 +852,7 @@ impl Renderer { |
| 771 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], | 852 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], |
| 772 | uv: [x / width, if flipped { 1.0 - v } else { v }], | 853 | uv: [x / width, if flipped { 1.0 - v } else { v }], |
| 773 | color: [motion.opacity; 4], | 854 | color: [motion.opacity; 4], |
| 774 | local: [0.0; 2], | 855 | ..Vertex::zeroed() |
| 775 | shape: [0.0; 4], | ||
| 776 | stroke: 0.0, | ||
| 777 | clip_local: [0.0; 2], | ||
| 778 | clip: [0.0; 3], | ||
| 779 | blur: 0.0, | ||
| 780 | } | 856 | } |
| 781 | }; | 857 | }; |
| 782 | for strip in 0..strips { | 858 | for strip in 0..strips { |
| ... | @@ -790,10 +866,13 @@ impl Renderer { | ... | @@ -790,10 +866,13 @@ impl Renderer { |
| 790 | ]; | 866 | ]; |
| 791 | self.vertices.extend([a, b, c, a, c, d]); | 867 | self.vertices.extend([a, b, c, a, c, d]); |
| 792 | } | 868 | } |
| 793 | self.groups.push(Group { | 869 | let batches = self.batches.split_off(start); |
| 794 | batches, | 870 | self.batches.push(Batch { |
| 795 | composite: from..self.vertices.len() as u32, | 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 | Ok(()) | 876 | Ok(()) |
| 798 | } | 877 | } |
| 799 | 878 | ||
| ... | @@ -815,10 +894,7 @@ impl Renderer { | ... | @@ -815,10 +894,7 @@ impl Renderer { |
| 815 | tint, | 894 | tint, |
| 816 | palette, | 895 | palette, |
| 817 | } => { | 896 | } => { |
| 818 | let origin = [ | 897 | let origin = space.point(*origin); |
| 819 | space.origin[0] + origin[0] * space.scale, | ||
| 820 | space.origin[1] + origin[1] * space.scale, | ||
| 821 | ]; | ||
| 822 | self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?; | 898 | self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?; |
| 823 | } | 899 | } |
| 824 | Primitive::Path { | 900 | Primitive::Path { |
| ... | @@ -838,10 +914,7 @@ impl Renderer { | ... | @@ -838,10 +914,7 @@ impl Renderer { |
| 838 | clip, | 914 | clip, |
| 839 | ink, | 915 | ink, |
| 840 | } => { | 916 | } => { |
| 841 | let origin = [ | 917 | let origin = space.point(*origin); |
| 842 | space.origin[0] + origin[0] * space.scale, | ||
| 843 | space.origin[1] + origin[1] * space.scale, | ||
| 844 | ]; | ||
| 845 | if origin.iter().any(|v| !v.is_finite()) { | 918 | if origin.iter().any(|v| !v.is_finite()) { |
| 846 | return Err(RenderError::InvalidPrimitive); | 919 | return Err(RenderError::InvalidPrimitive); |
| 847 | } | 920 | } |
| ... | @@ -858,17 +931,7 @@ impl Renderer { | ... | @@ -858,17 +931,7 @@ impl Renderer { |
| 858 | text.runs(&mut |run| self.glyph_run(space, run, *ink))?; | 931 | text.runs(&mut |run| self.glyph_run(space, run, *ink))?; |
| 859 | } | 932 | } |
| 860 | Primitive::Rect { rect, color } => { | 933 | Primitive::Rect { rect, color } => { |
| 861 | self.quad( | 934 | self.quad(space, space.rect(*rect), self.white(), *color)?; |
| 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 | )?; | ||
| 872 | } | 935 | } |
| 873 | Primitive::RoundedRect { | 936 | Primitive::RoundedRect { |
| 874 | rect, | 937 | rect, |
| ... | @@ -993,7 +1056,7 @@ impl Renderer { | ... | @@ -993,7 +1056,7 @@ impl Renderer { |
| 993 | return Ok(()); | 1056 | return Ok(()); |
| 994 | } | 1057 | } |
| 995 | let size = run.size * scale; | 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 | return Err(RenderError::AtlasFull); | 1060 | return Err(RenderError::AtlasFull); |
| 998 | } | 1061 | } |
| 999 | let color = run | 1062 | let color = run |
| ... | @@ -1119,7 +1182,7 @@ impl Renderer { | ... | @@ -1119,7 +1182,7 @@ impl Renderer { |
| 1119 | 1182 | ||
| 1120 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { | 1183 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { |
| 1121 | let p = image.placement; | 1184 | let p = image.placement; |
| 1122 | let side = self.atlas_side(); | 1185 | let side = self.gpu.atlas_side(); |
| 1123 | if p.width + 2 > side || p.height + 2 > side { | 1186 | if p.width + 2 > side || p.height + 2 > side { |
| 1124 | return Err(RenderError::AtlasFull); | 1187 | return Err(RenderError::AtlasFull); |
| 1125 | } | 1188 | } |
| ... | @@ -1173,7 +1236,7 @@ impl Renderer { | ... | @@ -1173,7 +1236,7 @@ impl Renderer { |
| 1173 | { | 1236 | { |
| 1174 | return Ok(()); | 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 | return Err(RenderError::AtlasFull); | 1240 | return Err(RenderError::AtlasFull); |
| 1178 | } | 1241 | } |
| 1179 | let size = size as u32; | 1242 | let size = size as u32; |
| ... | @@ -1226,12 +1289,7 @@ impl Renderer { | ... | @@ -1226,12 +1289,7 @@ impl Renderer { |
| 1226 | width | 1289 | width |
| 1227 | } | 1290 | } |
| 1228 | }; | 1291 | }; |
| 1229 | let rect = [ | 1292 | let rect = space.rect(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 | ]; | ||
| 1235 | let start = self.vertices.len(); | 1293 | let start = self.vertices.len(); |
| 1236 | // A pixel of margin holds the antialiased fringe outside the edge. | 1294 | // A pixel of margin holds the antialiased fringe outside the edge. |
| 1237 | let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0]; | 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,7 +1331,7 @@ impl Renderer { |
| 1273 | if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 { | 1331 | if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 { |
| 1274 | return Err(RenderError::InvalidPrimitive); | 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 | let sigma = blur * space.scale / 2.0; | 1335 | let sigma = blur * space.scale / 2.0; |
| 1278 | let reach = 3.0 * sigma; | 1336 | let reach = 3.0 * sigma; |
| 1279 | let start = self.vertices.len(); | 1337 | let start = self.vertices.len(); |
| ... | @@ -1305,8 +1363,9 @@ impl Renderer { | ... | @@ -1305,8 +1363,9 @@ impl Renderer { |
| 1305 | style: PathStyle, | 1363 | style: PathStyle, |
| 1306 | colors: [[f32; 4]; 2], | 1364 | colors: [[f32; 4]; 2], |
| 1307 | ) -> Result<(), RenderError> { | 1365 | ) -> Result<(), RenderError> { |
| 1308 | let [x, y] = [0, 1] | 1366 | let [x, y] = space |
| 1309 | .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); | 1367 | .point(origin) |
| 1368 | .map(|value| (value * 4.0).round() * 0.25); | ||
| 1310 | let (kind, width) = match style { | 1369 | let (kind, width) = match style { |
| 1311 | PathStyle::Fill | PathStyle::Erase => (0, 0.0), | 1370 | PathStyle::Fill | PathStyle::Erase => (0, 0.0), |
| 1312 | PathStyle::Stroke(width) => (1, width), | 1371 | PathStyle::Stroke(width) => (1, width), |
| ... | @@ -1400,13 +1459,7 @@ impl Renderer { | ... | @@ -1400,13 +1459,7 @@ impl Renderer { |
| 1400 | round: bool, | 1459 | round: bool, |
| 1401 | color: [f32; 4], | 1460 | color: [f32; 4], |
| 1402 | ) -> Result<(), RenderError> { | 1461 | ) -> Result<(), RenderError> { |
| 1403 | let pixel = |p: [f32; 2]| { | 1462 | let [from, to] = [space.point(from), space.point(to)]; |
| 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)]; | ||
| 1410 | // Hairlines stay one device pixel wide. | 1463 | // Hairlines stay one device pixel wide. |
| 1411 | let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5); | 1464 | let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5); |
| 1412 | let radius = radii[0].max(radii[1]); | 1465 | let radius = radii[0].max(radii[1]); |
| ... | @@ -1463,14 +1516,12 @@ impl Renderer { | ... | @@ -1463,14 +1516,12 @@ impl Renderer { |
| 1463 | x * 2.0 / space.size[0] as f32 - 1.0, | 1516 | x * 2.0 / space.size[0] as f32 - 1.0, |
| 1464 | 1.0 - y * 2.0 / space.size[1] as f32, | 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 | color, | 1520 | color, |
| 1468 | local, | 1521 | local, |
| 1469 | shape, | 1522 | shape, |
| 1470 | stroke: 0.0, | ||
| 1471 | clip_local: [0.0; 2], | ||
| 1472 | clip: [0.0; 3], | ||
| 1473 | blur, | 1523 | blur, |
| 1524 | ..Vertex::zeroed() | ||
| 1474 | }); | 1525 | }); |
| 1475 | } | 1526 | } |
| 1476 | Ok(()) | 1527 | Ok(()) |
| ... | @@ -1520,11 +1571,7 @@ impl Renderer { | ... | @@ -1520,11 +1571,7 @@ impl Renderer { |
| 1520 | uv, | 1571 | uv, |
| 1521 | color, | 1572 | color, |
| 1522 | local, | 1573 | local, |
| 1523 | shape: [0.0; 4], | 1574 | ..Vertex::zeroed() |
| 1524 | stroke: 0.0, | ||
| 1525 | clip_local: [0.0; 2], | ||
| 1526 | clip: [0.0; 3], | ||
| 1527 | blur: 0.0, | ||
| 1528 | }); | 1575 | }); |
| 1529 | } | 1576 | } |
| 1530 | Ok(()) | 1577 | Ok(()) |
| ... | @@ -1585,12 +1632,7 @@ fn soften( | ... | @@ -1585,12 +1632,7 @@ fn soften( |
| 1585 | } | 1632 | } |
| 1586 | 1633 | ||
| 1587 | fn srgb_byte(value: f32) -> u8 { | 1634 | fn srgb_byte(value: f32) -> u8 { |
| 1588 | let srgb = if value <= 0.0031308 { | 1635 | (crate::encode(value) * 255.0).round() as u8 |
| 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 | ||
| 1594 | } | 1636 | } |
| 1595 | 1637 | ||
| 1596 | /// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light, | 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,11 +1641,11 @@ fn srgb_byte(value: f32) -> u8 { |
| 1599 | /// nearly untouched. | 1641 | /// nearly untouched. |
| 1600 | fn text_coverage(tone: u8) -> [u8; 256] { | 1642 | fn text_coverage(tone: u8) -> [u8; 256] { |
| 1601 | let encoded = f32::from(tone) / 16.0; | 1643 | let encoded = f32::from(tone) / 16.0; |
| 1602 | let ink = linear(encoded); | 1644 | let ink = crate::linear(encoded); |
| 1603 | std::array::from_fn(|coverage| { | 1645 | std::array::from_fn(|coverage| { |
| 1604 | let coverage = coverage as f32 / 255.0; | 1646 | let coverage = coverage as f32 / 255.0; |
| 1605 | let weighted = if ink < 0.999 { | 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 | } else { | 1649 | } else { |
| 1608 | coverage | 1650 | coverage |
| 1609 | }; | 1651 | }; |
| ... | @@ -1611,14 +1653,6 @@ fn text_coverage(tone: u8) -> [u8; 256] { | ... | @@ -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 | /// Least OKLab lightness difference between text and the backdrop it stays legible on. | 1656 | /// Least OKLab lightness difference between text and the backdrop it stays legible on. |
| 1623 | const LEGIBLE: f32 = 0.4; | 1657 | const LEGIBLE: f32 = 0.4; |
| 1624 | 1658 | ||
| ... | @@ -1670,7 +1704,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { | ... | @@ -1670,7 +1704,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { |
| 1670 | 1704 | ||
| 1671 | /// Linear RGBA of an opaque sRGB colour. | 1705 | /// Linear RGBA of an opaque sRGB colour. |
| 1672 | pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { | 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 | [red, green, blue, 1.0] | 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,18 +1715,18 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { |
| 1681 | 1715 | ||
| 1682 | /// The hue in degrees of a linear colour, as it looks in sRGB. | 1716 | /// The hue in degrees of a linear colour, as it looks in sRGB. |
| 1683 | pub fn hue(color: [f32; 4]) -> f32 { | 1717 | pub fn hue(color: [f32; 4]) -> f32 { |
| 1684 | let [r, g, b] = [color[0], color[1], color[2]].map(|value| { | 1718 | to_hsl([color[0], color[1], color[2]].map(crate::encode))[0] |
| 1685 | if value <= 0.003_130_8 { | 1719 | } |
| 1686 | value * 12.92 | 1720 | |
| 1687 | } else { | 1721 | /// Hue in degrees, saturation and lightness of an encoded sRGB colour. |
| 1688 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | 1722 | fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] { |
| 1689 | } | 1723 | let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; |
| 1690 | }); | 1724 | let lightness = (max + min) / 2.0; |
| 1691 | let max = r.max(g).max(b); | 1725 | let range = max - min; |
| 1692 | let range = max - r.min(g).min(b); | ||
| 1693 | if range == 0.0 { | 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 | let sector = if max == r { | 1730 | let sector = if max == r { |
| 1697 | (g - b) / range | 1731 | (g - b) / range |
| 1698 | } else if max == g { | 1732 | } else if max == g { |
| ... | @@ -1700,7 +1734,16 @@ pub fn hue(color: [f32; 4]) -> f32 { | ... | @@ -1700,7 +1734,16 @@ pub fn hue(color: [f32; 4]) -> f32 { |
| 1700 | } else { | 1734 | } else { |
| 1701 | (r - g) / range + 4.0 | 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 | /// The light theme's accent `[saturation, lightness]`, also the default ink of a section's pen. | 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,13 +1751,9 @@ pub const LIGHT_ACCENT: [f32; 2] = [0.60, 0.45]; |
| 1708 | 1751 | ||
| 1709 | /// An sRGB hue, saturation and lightness as linear RGBA. | 1752 | /// An sRGB hue, saturation and lightness as linear RGBA. |
| 1710 | pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] { | 1753 | pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] { |
| 1711 | let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; | 1754 | let [red, green, blue] = from_hsl([hue, saturation, lightness]) |
| 1712 | let channel = |offset: f32| { | 1755 | .map(|value| (value * 255.0).round().clamp(0.0, 255.0) as u8); |
| 1713 | let k = (offset + hue / 30.0).rem_euclid(12.0); | 1756 | srgb(red, green, blue) |
| 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)) | ||
| 1718 | } | 1757 | } |
| 1719 | 1758 | ||
| 1720 | #[cfg(all(test, feature = "wgpu"))] | 1759 | #[cfg(all(test, feature = "wgpu"))] |
| ... | @@ -2547,13 +2586,13 @@ mod tests { | ... | @@ -2547,13 +2586,13 @@ mod tests { |
| 2547 | target.draw(&mut renderer, &page(&primitives)).unwrap(); | 2586 | target.draw(&mut renderer, &page(&primitives)).unwrap(); |
| 2548 | } | 2587 | } |
| 2549 | assert_eq!( | 2588 | assert_eq!( |
| 2550 | renderer.atlas_side(), | 2589 | renderer.gpu.atlas_side(), |
| 2551 | ATLAS_SIZE, | 2590 | ATLAS_SIZE, |
| 2552 | "frames each needing a third of the atlas evict, never grow it" | 2591 | "frames each needing a third of the atlas evict, never grow it" |
| 2553 | ); | 2592 | ); |
| 2554 | let primitives = icons(0..3 * one_atlas); | 2593 | let primitives = icons(0..3 * one_atlas); |
| 2555 | target.draw(&mut renderer, &page(&primitives)).unwrap(); | 2594 | target.draw(&mut renderer, &page(&primitives)).unwrap(); |
| 2556 | assert!(renderer.atlas_side() > ATLAS_SIZE); | 2595 | assert!(renderer.gpu.atlas_side() > ATLAS_SIZE); |
| 2557 | assert!(renderer.glyphs.len() >= 3 * one_atlas); | 2596 | assert!(renderer.glyphs.len() >= 3 * one_atlas); |
| 2558 | let capture = target.capture(&renderer); | 2597 | let capture = target.capture(&renderer); |
| 2559 | let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | 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,10 +1,13 @@ |
| 1 | //! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1 | 1 | //! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11 |
| 2 | //! where it doesn't, as on Windows 7. Whoever makes the renderer picks one. | 2 | //! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the |
| 3 | use super::{gl, webgpu, *}; | 3 | //! renderer picks one. |
| 4 | use super::{d3d11, gl, webgpu, *}; | ||
| 5 | use std::ffi::c_void; | ||
| 4 | 6 | ||
| 5 | /// What a frame is drawn into, for the backend the renderer draws with. | 7 | /// What a frame is drawn into, for the backend the renderer draws with. |
| 6 | pub enum Target { | 8 | pub enum Target { |
| 7 | Wgpu(wgpu::TextureView), | 9 | Wgpu(wgpu::TextureView), |
| 10 | D3d11(d3d11::Target), | ||
| 8 | Gl(gl::Target), | 11 | Gl(gl::Target), |
| 9 | } | 12 | } |
| 10 | 13 | ||
| ... | @@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target { | ... | @@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target { |
| 14 | } | 17 | } |
| 15 | } | 18 | } |
| 16 | 19 | ||
| 17 | impl From<gl::Target> for Target { | 20 | impl Target { |
| 18 | fn from(target: gl::Target) -> Self { | 21 | pub fn size(&self) -> [u32; 2] { |
| 19 | Self::Gl(target) | 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 | pub(super) enum Image { | 30 | pub(super) enum Image { |
| 24 | Wgpu(webgpu::Image), | 31 | Wgpu(webgpu::Image), |
| 32 | D3d11(d3d11::Image), | ||
| 25 | Gl(gl::Image), | 33 | Gl(gl::Image), |
| 26 | } | 34 | } |
| 27 | 35 | ||
| ... | @@ -29,7 +37,16 @@ impl AsRef<webgpu::Image> for Image { | ... | @@ -29,7 +37,16 @@ impl AsRef<webgpu::Image> for Image { |
| 29 | fn as_ref(&self) -> &webgpu::Image { | 37 | fn as_ref(&self) -> &webgpu::Image { |
| 30 | match self { | 38 | match self { |
| 31 | Self::Wgpu(image) => image, | 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,74 +55,109 @@ impl AsRef<gl::Image> for Image { |
| 38 | fn as_ref(&self) -> &gl::Image { | 55 | fn as_ref(&self) -> &gl::Image { |
| 39 | match self { | 56 | match self { |
| 40 | Self::Gl(image) => image, | 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 | pub(super) enum Gpu { | 63 | pub(super) enum Gpu { |
| 47 | Wgpu(webgpu::Gpu), | 64 | Wgpu(webgpu::Gpu), |
| 65 | D3d11(d3d11::Gpu), | ||
| 48 | Gl(gl::Gpu), | 66 | Gl(gl::Gpu), |
| 49 | } | 67 | } |
| 50 | 68 | ||
| 51 | impl Renderer { | 69 | impl From<webgpu::Gpu> for Gpu { |
| 52 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | 70 | fn from(gpu: webgpu::Gpu) -> Self { |
| 53 | Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format))) | 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 | 81 | impl Renderer { |
| 57 | /// whenever the renderer or a `Target` is used. | 82 | /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's |
| 58 | pub fn opengl() -> Result<Self, String> { | 83 | /// description. |
| 59 | gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu))) | 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. | 91 | /// `$gpu` as whichever backend it holds, named `$backend` in `$body`. |
| 63 | pub fn present_translucent(&self, target: &gl::Target) { | 92 | macro_rules! each { |
| 64 | if let Gpu::Gl(gpu) = &self.gpu { | 93 | ($gpu:expr, $backend:ident => $body:expr) => { |
| 65 | gpu.present_translucent(target); | 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 | impl Gpu { | 102 | impl Gpu { |
| 71 | pub(super) fn flipped(&self) -> bool { | 103 | pub(super) fn target(&self, size: [u32; 2]) -> Result<Target, String> { |
| 72 | match self { | 104 | match self { |
| 73 | Self::Wgpu(gpu) => gpu.flipped(), | 105 | Self::D3d11(gpu) => gpu |
| 74 | Self::Gl(gpu) => gpu.flipped(), | 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 { | 114 | pub(super) fn present(&self, target: &Target, translucent: bool) { |
| 79 | match self { | 115 | match (self, target) { |
| 80 | Self::Wgpu(gpu) => gpu.max_texture_dimension(), | 116 | // Direct3D 11 premultiplies every frame again, which leaves opaque ones be. |
| 81 | Self::Gl(gpu) => gpu.max_texture_dimension(), | 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 { | 127 | pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> { |
| 86 | match self { | 128 | match (self, target) { |
| 87 | Self::Wgpu(gpu) => gpu.atlas_side(), | 129 | (Self::D3d11(gpu), Target::D3d11(target)) => { |
| 88 | Self::Gl(gpu) => gpu.atlas_side(), | 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 | pub(super) fn new_atlas(&mut self, side: u32) { | 149 | pub(super) fn new_atlas(&mut self, side: u32) { |
| 93 | match self { | 150 | each!(self, gpu => gpu.new_atlas(side)) |
| 94 | Self::Wgpu(gpu) => gpu.new_atlas(side), | ||
| 95 | Self::Gl(gpu) => gpu.new_atlas(side), | ||
| 96 | } | ||
| 97 | } | 151 | } |
| 98 | 152 | ||
| 99 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | 153 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| 100 | match self { | 154 | each!(self, gpu => gpu.write_atlas(origin, size, rgba)) |
| 101 | Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba), | ||
| 102 | Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba), | ||
| 103 | } | ||
| 104 | } | 155 | } |
| 105 | 156 | ||
| 106 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { | 157 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 107 | match self { | 158 | match self { |
| 108 | Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), | 159 | Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), |
| 160 | Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)), | ||
| 109 | Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), | 161 | Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), |
| 110 | } | 162 | } |
| 111 | } | 163 | } |
| ... | @@ -119,6 +171,7 @@ impl Gpu { | ... | @@ -119,6 +171,7 @@ impl Gpu { |
| 119 | ) { | 171 | ) { |
| 120 | match (self, target) { | 172 | match (self, target) { |
| 121 | (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), | 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 | (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), | 175 | (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), |
| 123 | _ => unreachable!("Frames are drawn into the renderer's backend's targets"), | 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 +7,6 @@ |
| 7 | use super::*; | 7 | use super::*; |
| 8 | use std::{ | 8 | use std::{ |
| 9 | ffi::{CStr, CString, c_char, c_void}, | 9 | ffi::{CStr, CString, c_char, c_void}, |
| 10 | mem::offset_of, | ||
| 11 | ptr, | 10 | ptr, |
| 12 | }; | 11 | }; |
| 13 | 12 | ||
| ... | @@ -250,9 +249,6 @@ functions! { | ... | @@ -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 | /// An sRGB RGBA texture, deleted with its value. | 252 | /// An sRGB RGBA texture, deleted with its value. |
| 257 | pub(super) struct Image { | 253 | pub(super) struct Image { |
| 258 | name: GLuint, | 254 | name: GLuint, |
| ... | @@ -305,9 +301,9 @@ pub struct Target { | ... | @@ -305,9 +301,9 @@ pub struct Target { |
| 305 | } | 301 | } |
| 306 | 302 | ||
| 307 | impl Target { | 303 | impl Target { |
| 308 | /// Needs the renderer's context current, as every method here does. | 304 | /// Sampled with `filter`. |
| 309 | pub fn new(size: [u32; 2]) -> Result<Self, String> { | 305 | fn new(size: [u32; 2], filter: GLint) -> Result<Self, String> { |
| 310 | let texture = Image::new(size, NEAREST, None); | 306 | let texture = Image::new(size, filter, None); |
| 311 | let mut framebuffer = 0; | 307 | let mut framebuffer = 0; |
| 312 | unsafe { | 308 | unsafe { |
| 313 | glGenFramebuffersEXT(1, &mut framebuffer); | 309 | glGenFramebuffersEXT(1, &mut framebuffer); |
| ... | @@ -329,53 +325,6 @@ impl Target { | ... | @@ -329,53 +325,6 @@ impl Target { |
| 329 | pub fn size(&self) -> [u32; 2] { | 325 | pub fn size(&self) -> [u32; 2] { |
| 330 | self.texture.size | 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 | impl Drop for Target { | 330 | impl Drop for Target { |
| ... | @@ -396,38 +345,10 @@ pub(super) struct Gpu { | ... | @@ -396,38 +345,10 @@ pub(super) struct Gpu { |
| 396 | groups: Vec<Target>, | 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 | impl Gpu { | 348 | impl Gpu { |
| 428 | pub(super) fn new() -> Result<Self, String> { | 349 | pub(super) fn new() -> Result<Self, String> { |
| 429 | load()?; | 350 | load()?; |
| 430 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); | 351 | let names = ATTRIBUTES.map(|(name, _, _)| name); |
| 431 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; | 352 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; |
| 432 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; | 353 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; |
| 433 | let [mut buffer, mut triangle] = [0; 2]; | 354 | let [mut buffer, mut triangle] = [0; 2]; |
| ... | @@ -445,7 +366,7 @@ impl Gpu { | ... | @@ -445,7 +366,7 @@ impl Gpu { |
| 445 | ); | 366 | ); |
| 446 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); | 367 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); |
| 447 | } | 368 | } |
| 448 | Ok(Gpu { | 369 | Ok(Self { |
| 449 | program: draw, | 370 | program: draw, |
| 450 | buffer, | 371 | buffer, |
| 451 | translucent, | 372 | translucent, |
| ... | @@ -456,7 +377,36 @@ impl Gpu { | ... | @@ -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 | let [width, height] = target.size().map(|side| side as f32); | 410 | let [width, height] = target.size().map(|side| side as f32); |
| 461 | let program = self.translucent; | 411 | let program = self.translucent; |
| 462 | unsafe { | 412 | unsafe { |
| ... | @@ -518,6 +468,31 @@ impl Gpu { | ... | @@ -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 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { | 496 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 522 | Image::new(image.size, LINEAR, Some(image.pixels())) | 497 | Image::new(image.size, LINEAR, Some(image.pixels())) |
| 523 | } | 498 | } |
| ... | @@ -539,14 +514,9 @@ impl Gpu { | ... | @@ -539,14 +514,9 @@ impl Gpu { |
| 539 | self.groups.clear(); | 514 | self.groups.clear(); |
| 540 | } | 515 | } |
| 541 | while self.groups.len() < frame.groups.len() { | 516 | while self.groups.len() < frame.groups.len() { |
| 542 | match Target::new(size) { | 517 | // Filtered, so a picture leaning back stays smooth. |
| 543 | Ok(picture) => unsafe { | 518 | match Target::new(size, LINEAR) { |
| 544 | // Filtered, so a picture leaning back stays smooth. | 519 | Ok(picture) => self.groups.push(picture), |
| 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 | }, | ||
| 550 | Err(error) => { | 520 | Err(error) => { |
| 551 | eprintln!("{error}"); | 521 | eprintln!("{error}"); |
| 552 | return; | 522 | return; |
| ... | @@ -555,36 +525,30 @@ impl Gpu { | ... | @@ -555,36 +525,30 @@ impl Gpu { |
| 555 | } | 525 | } |
| 556 | let [width, height] = size.map(|side| side as GLsizei); | 526 | let [width, height] = size.map(|side| side as GLsizei); |
| 557 | let gpu = &*self; | 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 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); | 529 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); |
| 560 | glDisable(SCISSOR_TEST); | 530 | glDisable(SCISSOR_TEST); |
| 561 | glClearColor(clear[0], clear[1], clear[2], clear[3]); | 531 | glClearColor(clear[0], clear[1], clear[2], clear[3]); |
| 562 | glClear(COLOR_BUFFER_BIT); | 532 | glClear(COLOR_BUFFER_BIT); |
| 563 | glEnable(SCISSOR_TEST); | 533 | glEnable(SCISSOR_TEST); |
| 564 | }; | ||
| 565 | let draw = |batches: &[Batch]| unsafe { | ||
| 566 | for batch in batches { | 534 | for batch in batches { |
| 567 | let [x, y, w, h] = batch.scissor.map(|value| value as GLint); | 535 | let [x, y, w, h] = batch.scissor.map(|value| value as GLint); |
| 568 | glScissor(x, height - y - h, w, h); | 536 | glScissor(x, height - y - h, w, h); |
| 569 | let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { | 537 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = Blend::STATES[batch.blend.state()] |
| 570 | Blend::Over => ( | 538 | .map(|factor| match factor { |
| 571 | gpu.atlas.name, | 539 | Factor::Zero => ZERO, |
| 572 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | 540 | Factor::One => ONE, |
| 573 | ), | 541 | Factor::SourceAlpha => SRC_ALPHA, |
| 574 | Blend::Image(id) => { | 542 | Factor::OneMinusSourceAlpha => ONE_MINUS_SRC_ALPHA, |
| 575 | let image: &Image = frame.images[&id].texture.as_ref(); | 543 | Factor::Destination => DST_COLOR, |
| 576 | (image.name, PREMULTIPLIED) | 544 | }); |
| 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 | }; | ||
| 586 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); | 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 | glDrawArrays( | 552 | glDrawArrays( |
| 589 | TRIANGLES, | 553 | TRIANGLES, |
| 590 | batch.vertices.start as GLint, | 554 | batch.vertices.start as GLint, |
| ... | @@ -605,38 +569,22 @@ impl Gpu { | ... | @@ -605,38 +569,22 @@ impl Gpu { |
| 605 | frame.vertices.as_ptr().cast(), | 569 | frame.vertices.as_ptr().cast(), |
| 606 | STREAM_DRAW, | 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 | glEnableVertexAttribArray(index as GLuint); | 573 | glEnableVertexAttribArray(index as GLuint); |
| 610 | glVertexAttribPointer( | 574 | glVertexAttribPointer( |
| 611 | index as GLuint, | 575 | index as GLuint, |
| 612 | *components, | 576 | floats as GLint, |
| 613 | FLOAT, | 577 | FLOAT, |
| 614 | 0, | 578 | 0, |
| 615 | size_of::<Vertex>() as GLsizei, | 579 | size_of::<Vertex>() as GLsizei, |
| 616 | ptr::without_provenance(*offset), | 580 | ptr::without_provenance(offset), |
| 617 | ); | 581 | ); |
| 618 | } | 582 | } |
| 619 | glEnable(BLEND); | 583 | glEnable(BLEND); |
| 620 | for (group, picture) in frame.groups.iter().zip(&gpu.groups) { | 584 | for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { |
| 621 | begin(picture, [0.0; 4]); | 585 | paint(picture, [0.0; 4], batches); |
| 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; | ||
| 638 | } | 586 | } |
| 639 | draw(&frame.batches[next..]); | 587 | paint(target, clear, frame.batches); |
| 640 | glDisable(SCISSOR_TEST); | 588 | glDisable(SCISSOR_TEST); |
| 641 | glDisable(BLEND); | 589 | glDisable(BLEND); |
| 642 | glBindFramebufferEXT(FRAMEBUFFER, 0); | 590 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
crates/draw/src/render/icon.rs+20-62| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | use super::{from_hsl, to_hsl}; | ||
| 1 | use swash::{ | 2 | use swash::{ |
| 2 | scale::image::{Content, Image}, | 3 | scale::image::{Content, Image}, |
| 3 | zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform}, | 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,11 +141,7 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa |
| 140 | let [x, y] = [index % side as usize, index / side as usize] | 141 | let [x, y] = [index % side as usize, index / side as usize] |
| 141 | .map(|device| device as f32 + 0.5) | 142 | .map(|device| device as f32 + 0.5) |
| 142 | .map(|device| device * 16.0 / size); | 143 | .map(|device| device * 16.0 / size); |
| 143 | let color = color([x, y]); | 144 | over(pixel, color([x, y]), alpha); |
| 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); | ||
| 148 | } | 145 | } |
| 149 | } | 146 | } |
| 150 | } | 147 | } |
| ... | @@ -224,13 +221,16 @@ fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) { | ... | @@ -224,13 +221,16 @@ fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) { |
| 224 | for (column, row, color) in shown.enumerate_pixels() { | 221 | for (column, row, color) in shown.enumerate_pixels() { |
| 225 | let pixel = &mut pixels[((top + row) * side + left + column) as usize]; | 222 | let pixel = &mut pixels[((top + row) * side + left + column) as usize]; |
| 226 | let [r, g, b, a] = color.0; | 223 | let [r, g, b, a] = color.0; |
| 227 | let alpha = f32::from(a) / 255.0; | 224 | over(pixel, super::srgb(r, g, b), f32::from(a) / 255.0); |
| 228 | let color = super::srgb(r, g, b); | 225 | } |
| 229 | for channel in 0..3 { | 226 | } |
| 230 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); | 227 | |
| 231 | } | 228 | /// Paints `color` at `alpha` over premultiplied `pixel`. |
| 232 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | 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 | fn hex(value: &str) -> [f32; 4] { | 236 | fn hex(value: &str) -> [f32; 4] { |
| ... | @@ -364,11 +364,11 @@ impl Shift { | ... | @@ -364,11 +364,11 @@ impl Shift { |
| 364 | let mean: [f32; 3] = std::array::from_fn(|channel| { | 364 | let mean: [f32; 3] = std::array::from_fn(|channel| { |
| 365 | colors | 365 | colors |
| 366 | .iter() | 366 | .iter() |
| 367 | .map(|color| encode(color[channel])) | 367 | .map(|color| crate::encode(color[channel])) |
| 368 | .sum::<f32>() | 368 | .sum::<f32>() |
| 369 | / count | 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 | (count > 0.0).then(|| { | 372 | (count > 0.0).then(|| { |
| 373 | Self([ | 373 | Self([ |
| 374 | to[0] - from[0], | 374 | to[0] - from[0], |
| ... | @@ -379,61 +379,19 @@ impl Shift { | ... | @@ -379,61 +379,19 @@ impl Shift { |
| 379 | } | 379 | } |
| 380 | 380 | ||
| 381 | fn apply(&self, color: [f32; 4]) -> [f32; 4] { | 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 | let [turn, scale, lift] = self.0; | 384 | let [turn, scale, lift] = self.0; |
| 384 | let [r, g, b] = rgb([ | 385 | let [r, g, b] = from_hsl([ |
| 385 | (hue + turn).rem_euclid(360.0), | 386 | (hue + turn).rem_euclid(360.0), |
| 386 | (saturation * scale).clamp(0.0, 1.0), | 387 | (saturation * scale).clamp(0.0, 1.0), |
| 387 | (lightness + lift).clamp(0.0, 1.0), | 388 | (lightness + lift).clamp(0.0, 1.0), |
| 388 | ]) | 389 | ]) |
| 389 | .map(decode); | 390 | .map(crate::linear); |
| 390 | [r, g, b, color[3]] | 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 | /// The tight box around a path's geometry, curves included, as SVG's bounding box is. | 395 | /// The tight box around a path's geometry, curves included, as SVG's bounding box is. |
| 438 | fn bounds(data: &str) -> [[f32; 2]; 2] { | 396 | fn bounds(data: &str) -> [[f32; 2]; 2] { |
| 439 | struct Sampled { | 397 | struct Sampled { |
| ... | @@ -567,11 +525,11 @@ mod tests { | ... | @@ -567,11 +525,11 @@ mod tests { |
| 567 | let image = rasterize(&[ART], 16, [0.0; 3], palette); | 525 | let image = rasterize(&[ART], 16, [0.0; 3], palette); |
| 568 | let at = (y * 16 + 8) * 4; | 526 | let at = (y * 16 + 8) * 4; |
| 569 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); | 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 | let plain = Palette::default(); | 530 | let plain = Palette::default(); |
| 573 | let green = Palette { | 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 | ..plain | 533 | ..plain |
| 576 | }; | 534 | }; |
| 577 | let [light, dark] = [2, 6].map(|y| pixel(&green, y)); | 535 | let [light, dark] = [2, 6].map(|y| pixel(&green, y)); |
| ... | @@ -583,7 +541,7 @@ mod tests { | ... | @@ -583,7 +541,7 @@ mod tests { |
| 583 | assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); | 541 | assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); |
| 584 | assert_eq!(pixel(&green, 10), pixel(&plain, 10)); | 542 | assert_eq!(pixel(&green, 10), pixel(&plain, 10)); |
| 585 | assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]); | 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 | #[test] | 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,7 +37,6 @@ pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result<Vec<u8>, RenderError> { |
| 37 | images: HashMap::new(), | 37 | images: HashMap::new(), |
| 38 | states: HashMap::new(), | 38 | states: HashMap::new(), |
| 39 | scale: ScaleContext::new(), | 39 | scale: ScaleContext::new(), |
| 40 | rows: None, | ||
| 41 | }; | 40 | }; |
| 42 | let catalog = document.reserve(); | 41 | let catalog = document.reserve(); |
| 43 | let tree = document.reserve(); | 42 | let tree = document.reserve(); |
| ... | @@ -101,9 +100,6 @@ struct Document { | ... | @@ -101,9 +100,6 @@ struct Document { |
| 101 | /// Graphics states by fill and stroke opacity bits and whether they multiply. | 100 | /// Graphics states by fill and stroke opacity bits and whether they multiply. |
| 102 | states: HashMap<(u32, bool), Ref>, | 101 | states: HashMap<(u32, bool), Ref>, |
| 103 | scale: ScaleContext, | 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 | #[derive(Hash, PartialEq, Eq)] | 105 | #[derive(Hash, PartialEq, Eq)] |
| ... | @@ -258,7 +254,9 @@ impl Document { | ... | @@ -258,7 +254,9 @@ impl Document { |
| 258 | .clip_nonzero() | 254 | .clip_nonzero() |
| 259 | .end_path(); | 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 | .clip | 260 | .clip |
| 263 | .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); | 261 | .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); |
| 264 | content.transform([ | 262 | content.transform([ |
| ... | @@ -270,7 +268,7 @@ impl Document { | ... | @@ -270,7 +268,7 @@ impl Document { |
| 270 | layer.origin[1], | 268 | layer.origin[1], |
| 271 | ]); | 269 | ]); |
| 272 | for primitive in layer.primitives { | 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 | content.restore_state(); | 273 | content.restore_state(); |
| 276 | } | 274 | } |
| ... | @@ -290,28 +288,30 @@ impl Document { | ... | @@ -290,28 +288,30 @@ impl Document { |
| 290 | content.set_fill_rgb(red, green, blue); | 288 | content.set_fill_rgb(red, green, blue); |
| 291 | } | 289 | } |
| 292 | if color[3] < 1.0 { | 290 | if color[3] < 1.0 { |
| 293 | let state = self.state(color[3], false); | 291 | self.opacity(content, used, color[3], false); |
| 294 | Used::add(&mut used.states, state); | ||
| 295 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 296 | } | 292 | } |
| 297 | } | 293 | } |
| 298 | 294 | ||
| 299 | fn state(&mut self, opacity: f32, multiply: bool) -> Ref { | 295 | /// Paints at `opacity`, multiplying what lies beneath if `multiply`. |
| 300 | let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply); | 296 | fn opacity(&mut self, content: &mut Content, used: &mut Used, opacity: f32, multiply: bool) { |
| 301 | if let Some(state) = self.states.get(&key) { | 297 | let opacity = opacity.clamp(0.0, 1.0); |
| 302 | return *state; | 298 | let key = (opacity.to_bits(), multiply); |
| 303 | } | 299 | let state = match self.states.get(&key) { |
| 304 | let state = self.reserve(); | 300 | Some(state) => *state, |
| 305 | let mut writer = self.pdf.ext_graphics(state); | 301 | None => { |
| 306 | writer | 302 | let state = self.reserve(); |
| 307 | .non_stroking_alpha(opacity.clamp(0.0, 1.0)) | 303 | let mut writer = self.pdf.ext_graphics(state); |
| 308 | .stroking_alpha(opacity.clamp(0.0, 1.0)); | 304 | writer.non_stroking_alpha(opacity).stroking_alpha(opacity); |
| 309 | if multiply { | 305 | if multiply { |
| 310 | writer.blend_mode(BlendMode::Multiply); | 306 | writer.blend_mode(BlendMode::Multiply); |
| 311 | } | 307 | } |
| 312 | writer.finish(); | 308 | writer.finish(); |
| 313 | self.states.insert(key, state); | 309 | self.states.insert(key, state); |
| 314 | 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 | fn primitive( | 317 | fn primitive( |
| ... | @@ -319,6 +319,7 @@ impl Document { | ... | @@ -319,6 +319,7 @@ impl Document { |
| 319 | content: &mut Content, | 319 | content: &mut Content, |
| 320 | used: &mut Used, | 320 | used: &mut Used, |
| 321 | primitive: &Primitive<'_>, | 321 | primitive: &Primitive<'_>, |
| 322 | rows: Option<[f32; 2]>, | ||
| 322 | ) -> Result<(), RenderError> { | 323 | ) -> Result<(), RenderError> { |
| 323 | match primitive { | 324 | match primitive { |
| 324 | Primitive::Text { | 325 | Primitive::Text { |
| ... | @@ -334,7 +335,7 @@ impl Document { | ... | @@ -334,7 +335,7 @@ impl Document { |
| 334 | .clip_nonzero() | 335 | .clip_nonzero() |
| 335 | .end_path(); | 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 | content.restore_state(); | 339 | content.restore_state(); |
| 339 | } | 340 | } |
| 340 | Primitive::Icon { | 341 | Primitive::Icon { |
| ... | @@ -364,9 +365,7 @@ impl Document { | ... | @@ -364,9 +365,7 @@ impl Document { |
| 364 | }; | 365 | }; |
| 365 | content.save_state(); | 366 | content.save_state(); |
| 366 | if tint[3] < 1.0 { | 367 | if tint[3] < 1.0 { |
| 367 | let state = self.state(tint[3], false); | 368 | self.opacity(content, used, tint[3], false); |
| 368 | Used::add(&mut used.states, state); | ||
| 369 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 370 | } | 369 | } |
| 371 | self.place( | 370 | self.place( |
| 372 | content, | 371 | content, |
| ... | @@ -603,9 +602,7 @@ impl Document { | ... | @@ -603,9 +602,7 @@ impl Document { |
| 603 | color, | 602 | color, |
| 604 | } => { | 603 | } => { |
| 605 | content.save_state(); | 604 | content.save_state(); |
| 606 | let state = self.state(color[3], true); | 605 | self.opacity(content, used, color[3], true); |
| 607 | Used::add(&mut used.states, state); | ||
| 608 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 609 | let [red, green, blue] = encoded(*color); | 606 | let [red, green, blue] = encoded(*color); |
| 610 | content | 607 | content |
| 611 | .set_stroke_rgb(red, green, blue) | 608 | .set_stroke_rgb(red, green, blue) |
| ... | @@ -649,7 +646,7 @@ impl Document { | ... | @@ -649,7 +646,7 @@ impl Document { |
| 649 | return [pixel[0], pixel[1], pixel[2], alpha]; | 646 | return [pixel[0], pixel[1], pixel[2], alpha]; |
| 650 | } | 647 | } |
| 651 | let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| { | 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 | srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) | 650 | srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) |
| 654 | }); | 651 | }); |
| 655 | [red, green, blue, alpha] | 652 | [red, green, blue, alpha] |
| ... | @@ -756,15 +753,13 @@ impl Document { | ... | @@ -756,15 +753,13 @@ impl Document { |
| 756 | run: GlyphRun<'_>, | 753 | run: GlyphRun<'_>, |
| 757 | origin: [f32; 2], | 754 | origin: [f32; 2], |
| 758 | ink: [f32; 4], | 755 | ink: [f32; 4], |
| 756 | rows: Option<[f32; 2]>, | ||
| 759 | ) -> Result<(), RenderError> { | 757 | ) -> Result<(), RenderError> { |
| 760 | let [top, bottom] = [ | 758 | let [top, bottom] = [ |
| 761 | origin[1] + run.line[0], | 759 | origin[1] + run.line[0], |
| 762 | origin[1] + run.line[0] + run.line[1], | 760 | origin[1] + run.line[0] + run.line[1], |
| 763 | ]; | 761 | ]; |
| 764 | if self | 762 | if rows.is_some_and(|[first, last]| bottom <= first || top >= last) { |
| 765 | .rows | ||
| 766 | .is_some_and(|[first, last]| bottom <= first || top >= last) | ||
| 767 | { | ||
| 768 | return Ok(()); | 763 | return Ok(()); |
| 769 | } | 764 | } |
| 770 | let font = self.font(run.font, run.index)?; | 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,30 +17,29 @@ impl AsRef<Image> for Image { |
| 17 | pub(super) struct Gpu { | 17 | pub(super) struct Gpu { |
| 18 | device: wgpu::Device, | 18 | device: wgpu::Device, |
| 19 | queue: wgpu::Queue, | 19 | queue: wgpu::Queue, |
| 20 | pipeline: wgpu::RenderPipeline, | 20 | layout: wgpu::BindGroupLayout, |
| 21 | image_pipeline: wgpu::RenderPipeline, | 21 | /// A pipeline for each of `Blend::STATES`. |
| 22 | erase_pipeline: wgpu::RenderPipeline, | 22 | pipelines: [wgpu::RenderPipeline; 4], |
| 23 | multiply_pipeline: wgpu::RenderPipeline, | 23 | atlas: (wgpu::Texture, wgpu::BindGroup), |
| 24 | atlas: wgpu::Texture, | 24 | nearest: wgpu::Sampler, |
| 25 | bind_group: wgpu::BindGroup, | 25 | linear: wgpu::Sampler, |
| 26 | image_sampler: wgpu::Sampler, | ||
| 27 | vertex_buffer: wgpu::Buffer, | 26 | vertex_buffer: wgpu::Buffer, |
| 28 | format: wgpu::TextureFormat, | 27 | format: wgpu::TextureFormat, |
| 29 | /// Offscreen pictures for groups, the target's size, and how each is sampled. | 28 | /// Offscreen pictures for groups, the target's size, and how each is sampled. |
| 30 | groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>, | 29 | groups: Vec<(wgpu::Texture, wgpu::BindGroup)>, |
| 31 | groups_size: [u32; 2], | ||
| 32 | } | 30 | } |
| 33 | 31 | ||
| 34 | #[cfg(not(windows))] | ||
| 35 | impl Renderer { | 32 | impl Renderer { |
| 36 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | 33 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { |
| 37 | Self::with_gpu(Gpu::new(device, queue, format)) | 34 | Self::with_gpu(Gpu::new(device, queue, format)) |
| 38 | } | 35 | } |
| 39 | 36 | ||
| 37 | #[cfg(not(windows))] | ||
| 40 | pub fn device(&self) -> &wgpu::Device { | 38 | pub fn device(&self) -> &wgpu::Device { |
| 41 | &self.gpu.device | 39 | &self.gpu.device |
| 42 | } | 40 | } |
| 43 | 41 | ||
| 42 | #[cfg(not(windows))] | ||
| 44 | pub fn queue(&self) -> &wgpu::Queue { | 43 | pub fn queue(&self) -> &wgpu::Queue { |
| 45 | &self.gpu.queue | 44 | &self.gpu.queue |
| 46 | } | 45 | } |
| ... | @@ -53,7 +52,7 @@ impl Gpu { | ... | @@ -53,7 +52,7 @@ impl Gpu { |
| 53 | format: wgpu::TextureFormat, | 52 | format: wgpu::TextureFormat, |
| 54 | ) -> Self { | 53 | ) -> Self { |
| 55 | let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); | 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 | label: Some("Draw texture"), | 56 | label: Some("Draw texture"), |
| 58 | entries: &[ | 57 | entries: &[ |
| 59 | wgpu::BindGroupLayoutEntry { | 58 | wgpu::BindGroupLayoutEntry { |
| ... | @@ -76,10 +75,23 @@ impl Gpu { | ... | @@ -76,10 +75,23 @@ impl Gpu { |
| 76 | }); | 75 | }); |
| 77 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { | 76 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { |
| 78 | label: Some("Draw"), | 77 | label: Some("Draw"), |
| 79 | bind_group_layouts: &[Some(&binding_layout)], | 78 | bind_group_layouts: &[Some(&layout)], |
| 80 | ..Default::default() | 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 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { | 95 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { |
| 84 | label: Some("Draw"), | 96 | label: Some("Draw"), |
| 85 | layout: Some(&pipeline_layout), | 97 | layout: Some(&pipeline_layout), |
| ... | @@ -90,7 +102,7 @@ impl Gpu { | ... | @@ -90,7 +102,7 @@ impl Gpu { |
| 90 | buffers: &[Some(wgpu::VertexBufferLayout { | 102 | buffers: &[Some(wgpu::VertexBufferLayout { |
| 91 | array_stride: size_of::<Vertex>() as u64, | 103 | array_stride: size_of::<Vertex>() as u64, |
| 92 | step_mode: wgpu::VertexStepMode::Vertex, | 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 | fragment: Some(wgpu::FragmentState { | 108 | fragment: Some(wgpu::FragmentState { |
| ... | @@ -99,7 +111,10 @@ impl Gpu { | ... | @@ -99,7 +111,10 @@ impl Gpu { |
| 99 | compilation_options: Default::default(), | 111 | compilation_options: Default::default(), |
| 100 | targets: &[Some(wgpu::ColorTargetState { | 112 | targets: &[Some(wgpu::ColorTargetState { |
| 101 | format, | 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 | write_mask: wgpu::ColorWrites::ALL, | 118 | write_mask: wgpu::ColorWrites::ALL, |
| 104 | })], | 119 | })], |
| 105 | }), | 120 | }), |
| ... | @@ -109,63 +124,33 @@ impl Gpu { | ... | @@ -109,63 +124,33 @@ impl Gpu { |
| 109 | multiview_mask: None, | 124 | multiview_mask: None, |
| 110 | cache: None, | 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 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { | 128 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { |
| 144 | label: Some("Draw vertices"), | 129 | label: Some("Draw vertices"), |
| 145 | size: VERTEX_BUFFER_BYTES, | 130 | size: VERTEX_BUFFER_BYTES, |
| 146 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | 131 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, |
| 147 | mapped_at_creation: false, | 132 | mapped_at_creation: false, |
| 148 | }); | 133 | }); |
| 149 | let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { | 134 | let sampler = |filter| { |
| 150 | label: Some("Draw image filtering"), | 135 | device.create_sampler(&wgpu::SamplerDescriptor { |
| 151 | mag_filter: wgpu::FilterMode::Linear, | 136 | mag_filter: filter, |
| 152 | min_filter: wgpu::FilterMode::Linear, | 137 | min_filter: filter, |
| 153 | ..Default::default() | 138 | ..Default::default() |
| 154 | }); | 139 | }) |
| 140 | }; | ||
| 141 | let nearest = sampler(wgpu::FilterMode::Nearest); | ||
| 142 | let atlas = atlas(&device, &layout, &nearest, ATLAS_SIZE); | ||
| 155 | Self { | 143 | Self { |
| 144 | linear: sampler(wgpu::FilterMode::Linear), | ||
| 145 | nearest, | ||
| 146 | atlas, | ||
| 156 | device, | 147 | device, |
| 157 | queue, | 148 | queue, |
| 158 | pipeline, | 149 | layout, |
| 159 | image_pipeline, | 150 | pipelines, |
| 160 | erase_pipeline, | ||
| 161 | multiply_pipeline, | ||
| 162 | atlas, | ||
| 163 | bind_group, | ||
| 164 | image_sampler, | ||
| 165 | vertex_buffer, | 151 | vertex_buffer, |
| 166 | format, | 152 | format, |
| 167 | groups: Vec::new(), | 153 | groups: Vec::new(), |
| 168 | groups_size: [0; 2], | ||
| 169 | } | 154 | } |
| 170 | } | 155 | } |
| 171 | 156 | ||
| ... | @@ -178,11 +163,11 @@ impl Gpu { | ... | @@ -178,11 +163,11 @@ impl Gpu { |
| 178 | } | 163 | } |
| 179 | 164 | ||
| 180 | pub(super) fn atlas_side(&self) -> u32 { | 165 | pub(super) fn atlas_side(&self) -> u32 { |
| 181 | self.atlas.width() | 166 | self.atlas.0.width() |
| 182 | } | 167 | } |
| 183 | 168 | ||
| 184 | pub(super) fn new_atlas(&mut self, side: u32) { | 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 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { | 173 | pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { |
| ... | @@ -193,7 +178,7 @@ impl Gpu { | ... | @@ -193,7 +178,7 @@ impl Gpu { |
| 193 | y: origin[1], | 178 | y: origin[1], |
| 194 | z: 0, | 179 | z: 0, |
| 195 | }, | 180 | }, |
| 196 | ..self.atlas.as_image_copy() | 181 | ..self.atlas.0.as_image_copy() |
| 197 | }, | 182 | }, |
| 198 | rgba, | 183 | rgba, |
| 199 | wgpu::TexelCopyBufferLayout { | 184 | wgpu::TexelCopyBufferLayout { |
| ... | @@ -210,21 +195,12 @@ impl Gpu { | ... | @@ -210,21 +195,12 @@ impl Gpu { |
| 210 | } | 195 | } |
| 211 | 196 | ||
| 212 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { | 197 | pub(super) fn upload_image(&self, image: &RasterImage) -> Image { |
| 213 | let size = wgpu::Extent3d { | 198 | let texture = texture( |
| 214 | width: image.size[0], | 199 | &self.device, |
| 215 | height: image.size[1], | 200 | image.size, |
| 216 | depth_or_array_layers: 1, | 201 | wgpu::TextureFormat::Rgba8UnormSrgb, |
| 217 | }; | 202 | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, |
| 218 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | 203 | ); |
| 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 | }); | ||
| 228 | self.queue.write_texture( | 204 | self.queue.write_texture( |
| 229 | texture.as_image_copy(), | 205 | texture.as_image_copy(), |
| 230 | image.pixels(), | 206 | image.pixels(), |
| ... | @@ -233,66 +209,9 @@ impl Gpu { | ... | @@ -233,66 +209,9 @@ impl Gpu { |
| 233 | bytes_per_row: Some(image.size[0] * 4), | 209 | bytes_per_row: Some(image.size[0] * 4), |
| 234 | rows_per_image: Some(image.size[1]), | 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 { | 214 | Image(binding(&self.device, &self.layout, &texture, &self.linear)) |
| 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 | } | ||
| 296 | } | 215 | } |
| 297 | 216 | ||
| 298 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | 217 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared |
| ... | @@ -304,30 +223,28 @@ impl Gpu { | ... | @@ -304,30 +223,28 @@ impl Gpu { |
| 304 | size: [u32; 2], | 223 | size: [u32; 2], |
| 305 | clear: [f32; 4], | 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 | let gpu = &*self; | 243 | let gpu = &*self; |
| 309 | gpu.queue | 244 | gpu.queue |
| 310 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); | 245 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); |
| 311 | let mut encoder = gpu.device.create_command_encoder(&Default::default()); | 246 | let mut encoder = gpu.device.create_command_encoder(&Default::default()); |
| 312 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { | 247 | let mut paint = |view: &wgpu::TextureView, clear: [f32; 4], 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]| { | ||
| 331 | let [r, g, b, a] = clear.map(f64::from); | 248 | let [r, g, b, a] = clear.map(f64::from); |
| 332 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | 249 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { |
| 333 | label: Some("Draw"), | 250 | label: Some("Draw"), |
| ... | @@ -343,67 +260,102 @@ impl Gpu { | ... | @@ -343,67 +260,102 @@ impl Gpu { |
| 343 | ..Default::default() | 260 | ..Default::default() |
| 344 | }); | 261 | }); |
| 345 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); | 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) { | 276 | for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) { |
| 349 | let mut pass = begin(&mut encoder, view, [0.0; 4]); | 277 | paint(&picture.create_view(&Default::default()), [0.0; 4], batches); |
| 350 | draw(&mut pass, &frame.batches[group.batches.clone()]); | ||
| 351 | } | 278 | } |
| 352 | let mut pass = begin(&mut encoder, target, clear); | 279 | paint(target, clear, frame.batches); |
| 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); | ||
| 364 | gpu.queue.submit([encoder.finish()]); | 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. | 284 | fn component([source, destination]: [Factor; 2]) -> wgpu::BlendComponent { |
| 369 | fn atlas( | 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 | device: &wgpu::Device, | 300 | device: &wgpu::Device, |
| 371 | pipeline: &wgpu::RenderPipeline, | 301 | [width, height]: [u32; 2], |
| 372 | side: u32, | 302 | format: wgpu::TextureFormat, |
| 373 | ) -> (wgpu::Texture, wgpu::BindGroup) { | 303 | usage: wgpu::TextureUsages, |
| 374 | let atlas = device.create_texture(&wgpu::TextureDescriptor { | 304 | ) -> wgpu::Texture { |
| 375 | label: Some("Glyph atlas"), | 305 | device.create_texture(&wgpu::TextureDescriptor { |
| 306 | label: Some("Draw"), | ||
| 376 | size: wgpu::Extent3d { | 307 | size: wgpu::Extent3d { |
| 377 | width: side, | 308 | width, |
| 378 | height: side, | 309 | height, |
| 379 | depth_or_array_layers: 1, | 310 | depth_or_array_layers: 1, |
| 380 | }, | 311 | }, |
| 381 | mip_level_count: 1, | 312 | mip_level_count: 1, |
| 382 | sample_count: 1, | 313 | sample_count: 1, |
| 383 | dimension: wgpu::TextureDimension::D2, | 314 | dimension: wgpu::TextureDimension::D2, |
| 384 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | 315 | format, |
| 385 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | 316 | usage, |
| 386 | view_formats: &[], | 317 | view_formats: &[], |
| 387 | }); | 318 | }) |
| 388 | let view = atlas.create_view(&Default::default()); | 319 | } |
| 389 | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | 320 | |
| 390 | mag_filter: wgpu::FilterMode::Nearest, | 321 | /// How the pipelines sample `texture` through `sampler`. |
| 391 | min_filter: wgpu::FilterMode::Nearest, | 322 | fn binding( |
| 392 | ..Default::default() | 323 | device: &wgpu::Device, |
| 393 | }); | 324 | layout: &wgpu::BindGroupLayout, |
| 394 | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | 325 | texture: &wgpu::Texture, |
| 395 | label: Some("Glyph atlas"), | 326 | sampler: &wgpu::Sampler, |
| 396 | layout: &pipeline.get_bind_group_layout(0), | 327 | ) -> wgpu::BindGroup { |
| 328 | device.create_bind_group(&wgpu::BindGroupDescriptor { | ||
| 329 | label: Some("Draw"), | ||
| 330 | layout, | ||
| 397 | entries: &[ | 331 | entries: &[ |
| 398 | wgpu::BindGroupEntry { | 332 | wgpu::BindGroupEntry { |
| 399 | binding: 0, | 333 | binding: 0, |
| 400 | resource: wgpu::BindingResource::TextureView(&view), | 334 | resource: wgpu::BindingResource::TextureView( |
| 335 | &texture.create_view(&Default::default()), | ||
| 336 | ), | ||
| 401 | }, | 337 | }, |
| 402 | wgpu::BindGroupEntry { | 338 | wgpu::BindGroupEntry { |
| 403 | binding: 1, | 339 | binding: 1, |
| 404 | resource: wgpu::BindingResource::Sampler(&sampler), | 340 | resource: wgpu::BindingResource::Sampler(sampler), |
| 405 | }, | 341 | }, |
| 406 | ], | 342 | ], |
| 407 | }); | 343 | }) |
| 408 | (atlas, bind_group) | 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,6 +19,8 @@ pollster = "0.4" |
| 19 | resvg = { version = "0.48.1", default-features = false } | 19 | resvg = { version = "0.48.1", default-features = false } |
| 20 | serde = { version = "1", features = ["derive"] } | 20 | serde = { version = "1", features = ["derive"] } |
| 21 | serde_json = "1" | 21 | serde_json = "1" |
| 22 | # Clears a password handed in to unlock a section. | ||
| 23 | zeroize = "1.9.0" | ||
| 22 | 24 | ||
| 23 | [target.'cfg(target_os = "ios")'.dependencies] | 25 | [target.'cfg(target_os = "ios")'.dependencies] |
| 24 | objc2 = "0.5.2" | 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,6 +33,8 @@ char *sb_library_new_section(const Library *library, const char *folder, const c |
| 33 | const char *date, const char *time); | 33 | const char *date, const char *time); |
| 34 | char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); | 34 | char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); |
| 35 | char *sb_library_tagged(const Library *library, const Section *open, const char *path); | 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 | char *sb_library_sync_status(const Library *library); | 38 | char *sb_library_sync_status(const Library *library); |
| 37 | void sb_library_set_offline(const Library *library, bool offline); | 39 | void sb_library_set_offline(const Library *library, bool offline); |
| 38 | void sb_library_touched(const Library *library, const char *path); | 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,6 +58,7 @@ char *sb_section_themes(const Section *section, const char *id); |
| 56 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); | 58 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); |
| 57 | bool sb_section_flush(const Section *section, double seconds); | 59 | bool sb_section_flush(const Section *section, double seconds); |
| 58 | void sb_section_wake(const Section *section); | 60 | void sb_section_wake(const Section *section); |
| 61 | void sb_section_set_author(const Section *section, const char *author); | ||
| 59 | void sb_section_free(Section *section); | 62 | void sb_section_free(Section *section); |
| 60 | 63 | ||
| 61 | View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); | 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,6 +68,8 @@ bool sb_view_render(View *view); |
| 65 | bool sb_view_frame_pending(const View *view); | 68 | bool sb_view_frame_pending(const View *view); |
| 66 | void sb_view_set_dark(View *view, bool dark); | 69 | void sb_view_set_dark(View *view, bool dark); |
| 67 | bool sb_view_read_only(const View *view); | 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 | bool sb_view_reload(View *view, bool discard); | 73 | bool sb_view_reload(View *view, bool discard); |
| 69 | void sb_view_focus(View *view, bool focused); | 74 | void sb_view_focus(View *view, bool focused); |
| 70 | void sb_view_content(View *view, float bounds[4]); | 75 | void sb_view_content(View *view, float bounds[4]); |
crates/mobile/src/lib.rs+104-55| ... | @@ -37,7 +37,7 @@ use std::{ | ... | @@ -37,7 +37,7 @@ use std::{ |
| 37 | ffi::{CStr, CString, c_char, c_void}, | 37 | ffi::{CStr, CString, c_char, c_void}, |
| 38 | mem::ManuallyDrop, | 38 | mem::ManuallyDrop, |
| 39 | sync::{ | 39 | sync::{ |
| 40 | Arc, Mutex, | 40 | Arc, LazyLock, Mutex, MutexGuard, |
| 41 | atomic::{AtomicBool, Ordering}, | 41 | atomic::{AtomicBool, Ordering}, |
| 42 | }, | 42 | }, |
| 43 | task::{Wake, Waker}, | 43 | task::{Wake, Waker}, |
| ... | @@ -125,29 +125,19 @@ impl Canvas { | ... | @@ -125,29 +125,19 @@ impl Canvas { |
| 125 | /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. | 125 | /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. |
| 126 | fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> { | 126 | fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> { |
| 127 | let page = self.page.editor.page().ok()?; | 127 | let page = self.page.editor.page().ok()?; |
| 128 | let mut engine = ENGINES | 128 | let mut engine = pooled_engine(); |
| 129 | .lock() | ||
| 130 | .ok() | ||
| 131 | .and_then(|mut engines| engines.pop()) | ||
| 132 | .unwrap_or_default(); | ||
| 133 | let setup = canvas::print::Setup { | 129 | let setup = canvas::print::Setup { |
| 134 | paper, | 130 | paper, |
| 135 | fit_width: true, | 131 | fit_width: true, |
| 136 | footer: canvas::print::Footer::SectionAndPage, | 132 | footer: canvas::print::Footer::SectionAndPage, |
| 137 | }; | 133 | }; |
| 138 | let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); | 134 | let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); |
| 139 | if let Ok(mut engines) = ENGINES.lock() { | 135 | pool_engine(engine); |
| 140 | engines.push(engine); | ||
| 141 | } | ||
| 142 | pdf.ok() | 136 | pdf.ok() |
| 143 | } | 137 | } |
| 144 | 138 | ||
| 145 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { | 139 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { |
| 146 | let mut engine = ENGINES | 140 | let mut engine = pooled_engine(); |
| 147 | .lock() | ||
| 148 | .ok() | ||
| 149 | .and_then(|mut engines| engines.pop()) | ||
| 150 | .unwrap_or_default(); | ||
| 151 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | 141 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; |
| 152 | let mut page = PageView::new( | 142 | let mut page = PageView::new( |
| 153 | editor, | 143 | editor, |
| ... | @@ -159,7 +149,7 @@ impl Canvas { | ... | @@ -159,7 +149,7 @@ impl Canvas { |
| 159 | ); | 149 | ); |
| 160 | page.touch = true; | 150 | page.touch = true; |
| 161 | page.host_viewport = true; | 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 | Ok(Self { | 153 | Ok(Self { |
| 164 | page, | 154 | page, |
| 165 | space, | 155 | space, |
| ... | @@ -198,19 +188,7 @@ impl Canvas { | ... | @@ -198,19 +188,7 @@ impl Canvas { |
| 198 | /// What the page took since the last call as one edit, or none when it took nothing. | 188 | /// What the page took since the last call as one edit, or none when it took nothing. |
| 199 | fn edit(&mut self) -> Result<Option<Edit>> { | 189 | fn edit(&mut self) -> Result<Option<Edit>> { |
| 200 | let ops = self.page.editor.take_ops()?; | 190 | let ops = self.page.editor.take_ops()?; |
| 201 | if ops.is_empty() { | 191 | Ok((!ops.is_empty()).then(|| page_edit(self.space, ops))) |
| 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 | })) | ||
| 214 | } | 192 | } |
| 215 | 193 | ||
| 216 | fn device(&self, point: [f32; 2]) -> [f32; 2] { | 194 | fn device(&self, point: [f32; 2]) -> [f32; 2] { |
| ... | @@ -633,23 +611,33 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { | ... | @@ -633,23 +611,33 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { |
| 633 | /// rather than enumerating the system's fonts again. | 611 | /// rather than enumerating the system's fonts again. |
| 634 | static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new()); | 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 | /// The reader's Snap To Grid and Default font, which every page opened from then on takes, | 628 | /// The reader's Snap To Grid and Default font, which every page opened from then on takes, |
| 637 | /// and new pages' titles the font's face. | 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) { | 633 | fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> { |
| 641 | OPTIONS | 634 | OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) |
| 642 | .lock() | ||
| 643 | .unwrap_or_else(|error| error.into_inner()) | ||
| 644 | .clone() | ||
| 645 | .unwrap_or((true, canvas::editor::DefaultFont::default())) | ||
| 646 | } | 635 | } |
| 647 | 636 | ||
| 648 | /// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. | 637 | /// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. |
| 649 | #[unsafe(no_mangle)] | 638 | #[unsafe(no_mangle)] |
| 650 | pub extern "C" fn sb_set_snap_to_grid(on: bool) { | 639 | pub extern "C" fn sb_set_snap_to_grid(on: bool) { |
| 651 | let font = options().1; | 640 | options().0 = on; |
| 652 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font)); | ||
| 653 | } | 641 | } |
| 654 | 642 | ||
| 655 | /// OneNote's Default font: new text in `face` at `size` points, new titles in `face`. | 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,13 +646,11 @@ pub extern "C" fn sb_set_snap_to_grid(on: bool) { |
| 658 | /// `face` is NUL-terminated UTF-8. | 646 | /// `face` is NUL-terminated UTF-8. |
| 659 | #[unsafe(no_mangle)] | 647 | #[unsafe(no_mangle)] |
| 660 | pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { | 648 | pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { |
| 661 | let snap = options().0; | 649 | options().1 = canvas::editor::DefaultFont { |
| 662 | let font = canvas::editor::DefaultFont { | ||
| 663 | face: string(face), | 650 | face: string(face), |
| 664 | size, | 651 | size, |
| 665 | color: None, | 652 | color: None, |
| 666 | }; | 653 | }; |
| 667 | *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font)); | ||
| 668 | } | 654 | } |
| 669 | 655 | ||
| 670 | pub struct View { | 656 | pub struct View { |
| ... | @@ -676,6 +662,8 @@ pub struct View { | ... | @@ -676,6 +662,8 @@ pub struct View { |
| 676 | section: Arc<Shared>, | 662 | section: Arc<Shared>, |
| 677 | /// A conflict page, which shows what is stored and takes no edits. | 663 | /// A conflict page, which shows what is stored and takes no edits. |
| 678 | read_only: bool, | 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 | impl View { | 669 | impl View { |
| ... | @@ -731,13 +719,7 @@ impl View { | ... | @@ -731,13 +719,7 @@ impl View { |
| 731 | .unwrap_or_default(); | 719 | .unwrap_or_default(); |
| 732 | let restyle = onestore::op::restyle(&page, &sheet)?; | 720 | let restyle = onestore::op::restyle(&page, &sheet)?; |
| 733 | if !read_only && !restyle.is_empty() { | 721 | if !read_only && !restyle.is_empty() { |
| 734 | section.shared.apply(Edit { | 722 | section.shared.apply(page_edit(space, restyle))?; |
| 735 | at: library::filetime(), | ||
| 736 | ops: restyle | ||
| 737 | .into_iter() | ||
| 738 | .map(|op| Op::Page { space, op }) | ||
| 739 | .collect(), | ||
| 740 | })?; | ||
| 741 | page = section.shared.page(space)?.0; | 723 | page = section.shared.page(space)?.0; |
| 742 | } | 724 | } |
| 743 | let frame = Arc::<Frame>::default(); | 725 | let frame = Arc::<Frame>::default(); |
| ... | @@ -757,6 +739,7 @@ impl View { | ... | @@ -757,6 +739,7 @@ impl View { |
| 757 | frame, | 739 | frame, |
| 758 | section: Arc::clone(&section.shared), | 740 | section: Arc::clone(&section.shared), |
| 759 | read_only, | 741 | read_only, |
| 742 | reading: None, | ||
| 760 | }) | 743 | }) |
| 761 | } | 744 | } |
| 762 | 745 | ||
| ... | @@ -795,7 +778,7 @@ impl View { | ... | @@ -795,7 +778,7 @@ impl View { |
| 795 | fn stored(&mut self, result: Result<bool>) -> bool { | 778 | fn stored(&mut self, result: Result<bool>) -> bool { |
| 796 | let changed = report(result).unwrap_or(false); | 779 | let changed = report(result).unwrap_or(false); |
| 797 | match report(self.canvas.edit()) { | 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 | let _ = report(self.reload()); | 782 | let _ = report(self.reload()); |
| 800 | } | 783 | } |
| 801 | Some(Some(edit)) => { | 784 | Some(Some(edit)) => { |
| ... | @@ -830,17 +813,54 @@ impl View { | ... | @@ -830,17 +813,54 @@ impl View { |
| 830 | } | 813 | } |
| 831 | let (page, read_only) = self.section.page(self.canvas.space)?; | 814 | let (page, read_only) = self.section.page(self.canvas.space)?; |
| 832 | self.read_only = read_only; | 815 | self.read_only = read_only; |
| 816 | if let Some(column) = self.reading { | ||
| 817 | return self.show(page, Some(column)); | ||
| 818 | } | ||
| 833 | Ok(moved(self.canvas.page.refresh(page)?)) | 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 | impl Drop for View { | 851 | impl Drop for View { |
| 838 | fn drop(&mut self) { | 852 | fn drop(&mut self) { |
| 839 | // SAFETY: `canvas` is not used again. | 853 | // SAFETY: `canvas` is not used again. |
| 840 | let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) }; | 854 | let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) }; |
| 841 | if let Ok(mut engines) = ENGINES.lock() { | 855 | pool_engine(canvas.page.engine); |
| 842 | engines.push(canvas.page.engine); | 856 | } |
| 843 | } | 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,10 +938,39 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { |
| 918 | view.canvas.set_dark(dark); | 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 | #[unsafe(no_mangle)] | 942 | #[unsafe(no_mangle)] |
| 923 | pub extern "C" fn sb_view_read_only(view: &View) -> bool { | 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 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with | 976 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with |
| ... | @@ -1234,7 +1283,7 @@ struct Choices { | ... | @@ -1234,7 +1283,7 @@ struct Choices { |
| 1234 | templates: Vec<&'static str>, | 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 | let [red, green, blue, _] = colorref.to_le_bytes(); | 1287 | let [red, green, blue, _] = colorref.to_le_bytes(); |
| 1239 | [red, green, blue] | 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,7 +1448,7 @@ pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 |
| 1399 | let Some(date) = view.canvas.page.editor.date() else { | 1448 | let Some(date) = view.canvas.page.editor.date() else { |
| 1400 | return -1; | 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 | field as i8 | 1452 | field as i8 |
| 1404 | } | 1453 | } |
| 1405 | 1454 |
crates/mobile/src/library.rs+167-82| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | //! SMB share through the embedded client. A section opens through its replica in the app's | 2 | //! SMB share through the embedded client. A section opens through its replica in the app's |
| 3 | //! cache, which stores each edit at once and publishes it in the background. | 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 | use canvas::search::{Entry, Index, Query, Tagged}; | 6 | use canvas::search::{Entry, Index, Query, Tagged}; |
| 7 | use notebook::{ | 7 | use notebook::{ |
| 8 | Remote, Replica, | 8 | Remote, Replica, |
| ... | @@ -14,6 +14,7 @@ use onestore::{ | ... | @@ -14,6 +14,7 @@ use onestore::{ |
| 14 | CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, | 14 | CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, |
| 15 | op::{Edit, Op, SectionOp}, | 15 | op::{Edit, Op, SectionOp}, |
| 16 | page::Page, | 16 | page::Page, |
| 17 | protected::Key, | ||
| 17 | }; | 18 | }; |
| 18 | use std::{ | 19 | use std::{ |
| 19 | collections::{BTreeMap, HashMap}, | 20 | collections::{BTreeMap, HashMap}, |
| ... | @@ -249,6 +250,15 @@ pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) { | ... | @@ -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 | /// When a section's file was last checked for changes, and how it was then. | 262 | /// When a section's file was last checked for changes, and how it was then. |
| 253 | struct Checked { | 263 | struct Checked { |
| 254 | at: Instant, | 264 | at: Instant, |
| ... | @@ -267,6 +277,8 @@ pub struct Library { | ... | @@ -267,6 +277,8 @@ pub struct Library { |
| 267 | open: Mutex<Vec<(String, Weak<Shared>)>>, | 277 | open: Mutex<Vec<(String, Weak<Shared>)>>, |
| 268 | /// Work Offline, which sections opened later follow too. | 278 | /// Work Offline, which sections opened later follow too. |
| 269 | offline: AtomicBool, | 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 | /// How long edits wait for a pause in typing before they publish (`Section::set_pause`): | 282 | /// How long edits wait for a pause in typing before they publish (`Section::set_pause`): |
| 271 | /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent | 283 | /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent |
| 272 | /// with another device's a conflict version. | 284 | /// with another device's a conflict version. |
| ... | @@ -285,8 +297,12 @@ pub(crate) struct Tab { | ... | @@ -285,8 +297,12 @@ pub(crate) struct Tab { |
| 285 | pub(crate) group: String, | 297 | pub(crate) group: String, |
| 286 | /// The tab colour in sRGB. | 298 | /// The tab colour in sRGB. |
| 287 | pub(crate) color: [u8; 3], | 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 | pub(crate) readable: bool, | 302 | pub(crate) readable: bool, |
| 303 | /// Password protected and not unlocked (`sb_library_unlock`). | ||
| 304 | #[serde(default)] | ||
| 305 | pub(crate) locked: bool, | ||
| 290 | /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it. | 306 | /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it. |
| 291 | #[serde(default)] | 307 | #[serde(default)] |
| 292 | pub(crate) downloading: bool, | 308 | pub(crate) downloading: bool, |
| ... | @@ -295,9 +311,9 @@ pub(crate) struct Tab { | ... | @@ -295,9 +311,9 @@ pub(crate) struct Tab { |
| 295 | pub(crate) problem: Option<String>, | 311 | pub(crate) problem: Option<String>, |
| 296 | } | 312 | } |
| 297 | 313 | ||
| 298 | fn rgb(colorref: u32) -> [u8; 3] { | 314 | /// Titles keep OneNote's line breaks, which a one-line list shows as spaces. |
| 299 | let [red, green, blue, _] = colorref.to_le_bytes(); | 315 | fn one_line(title: &str) -> String { |
| 300 | [red, green, blue] | 316 | title.replace(|char: char| char.is_control(), " ") |
| 301 | } | 317 | } |
| 302 | 318 | ||
| 303 | fn stem(path: &str) -> String { | 319 | fn stem(path: &str) -> String { |
| ... | @@ -309,18 +325,21 @@ fn stem(path: &str) -> String { | ... | @@ -309,18 +325,21 @@ fn stem(path: &str) -> String { |
| 309 | 325 | ||
| 310 | /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps | 326 | /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps |
| 311 | /// deleted sections and pages in. | 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 | for section in &folder.sections { | 329 | for section in &folder.sections { |
| 314 | let (name, color, readable) = match &section.state { | 330 | let (name, color, readable) = match &section.state { |
| 315 | SectionState::Readable { name, color, .. } => (name.clone(), *color, true), | 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 | tabs.push(Tab { | 336 | tabs.push(Tab { |
| 319 | name: name.unwrap_or_else(|| stem(&section.path)), | 337 | name: name.unwrap_or_else(|| stem(&section.path)), |
| 320 | path: section.path.clone(), | 338 | path: section.path.clone(), |
| 321 | group: folder.path.clone(), | 339 | group: folder.path.clone(), |
| 322 | color: rgb(color.unwrap_or(SECTION_COLOR)), | 340 | color: srgb(color.unwrap_or(SECTION_COLOR)), |
| 323 | readable, | 341 | readable, |
| 342 | locked, | ||
| 324 | downloading: false, | 343 | downloading: false, |
| 325 | problem: None, | 344 | problem: None, |
| 326 | }); | 345 | }); |
| ... | @@ -330,8 +349,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { | ... | @@ -330,8 +349,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 330 | name: stem(&entry.path), | 349 | name: stem(&entry.path), |
| 331 | path: entry.path.clone(), | 350 | path: entry.path.clone(), |
| 332 | group: folder.path.clone(), | 351 | group: folder.path.clone(), |
| 333 | color: rgb(SECTION_COLOR), | 352 | color: srgb(SECTION_COLOR), |
| 334 | readable: false, | 353 | readable: false, |
| 354 | locked: false, | ||
| 335 | downloading: entry.reason == Reason::Evicted, | 355 | downloading: entry.reason == Reason::Evicted, |
| 336 | problem: match entry.reason { | 356 | problem: match entry.reason { |
| 337 | Reason::InUse => Some("Section in use".into()), | 357 | Reason::InUse => Some("Section in use".into()), |
| ... | @@ -342,7 +362,7 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { | ... | @@ -342,7 +362,7 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 342 | } | 362 | } |
| 343 | for group in &folder.groups { | 363 | for group in &folder.groups { |
| 344 | if !group.path.ends_with("OneNote_RecycleBin") { | 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,6 +397,7 @@ impl Library { |
| 377 | background, | 397 | background, |
| 378 | open: Mutex::default(), | 398 | open: Mutex::default(), |
| 379 | offline: AtomicBool::new(false), | 399 | offline: AtomicBool::new(false), |
| 400 | keys: Default::default(), | ||
| 380 | pause: if local { Duration::ZERO } else { PAUSE }, | 401 | pause: if local { Duration::ZERO } else { PAUSE }, |
| 381 | }) | 402 | }) |
| 382 | } | 403 | } |
| ... | @@ -399,6 +420,7 @@ impl Library { | ... | @@ -399,6 +420,7 @@ impl Library { |
| 399 | background: Some(background), | 420 | background: Some(background), |
| 400 | open: Mutex::default(), | 421 | open: Mutex::default(), |
| 401 | offline: AtomicBool::new(false), | 422 | offline: AtomicBool::new(false), |
| 423 | keys: Default::default(), | ||
| 402 | pause: Duration::ZERO, | 424 | pause: Duration::ZERO, |
| 403 | }; | 425 | }; |
| 404 | let reached = library.with_notebook(false, |_| Ok(())); | 426 | let reached = library.with_notebook(false, |_| Ok(())); |
| ... | @@ -501,6 +523,24 @@ impl Library { | ... | @@ -501,6 +523,24 @@ impl Library { |
| 501 | change(reopened) | 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 | /// The notebook's sections, read again. | 544 | /// The notebook's sections, read again. |
| 505 | pub(crate) fn tabs(&self) -> Result<Vec<Tab>> { | 545 | pub(crate) fn tabs(&self) -> Result<Vec<Tab>> { |
| 506 | if let Place::File(file) = &self.place { | 546 | if let Place::File(file) = &self.place { |
| ... | @@ -509,8 +549,9 @@ impl Library { | ... | @@ -509,8 +549,9 @@ impl Library { |
| 509 | name: stem(&path), | 549 | name: stem(&path), |
| 510 | path, | 550 | path, |
| 511 | group: String::new(), | 551 | group: String::new(), |
| 512 | color: rgb(SECTION_COLOR), | 552 | color: srgb(SECTION_COLOR), |
| 513 | readable: true, | 553 | readable: true, |
| 554 | locked: false, | ||
| 514 | downloading: false, | 555 | downloading: false, |
| 515 | problem: None, | 556 | problem: None, |
| 516 | }]); | 557 | }]); |
| ... | @@ -521,7 +562,7 @@ impl Library { | ... | @@ -521,7 +562,7 @@ impl Library { |
| 521 | tabs: Vec::new(), | 562 | tabs: Vec::new(), |
| 522 | files: BTreeMap::new(), | 563 | files: BTreeMap::new(), |
| 523 | }; | 564 | }; |
| 524 | tabs(notebook.catalog(), &mut listed.tabs); | 565 | tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs); |
| 525 | files(notebook.catalog(), &mut listed.files); | 566 | files(notebook.catalog(), &mut listed.files); |
| 526 | if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { | 567 | if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { |
| 527 | background.watch(notebook.replicas()); | 568 | background.watch(notebook.replicas()); |
| ... | @@ -586,25 +627,36 @@ impl Library { | ... | @@ -586,25 +627,36 @@ impl Library { |
| 586 | |file| Ok(Coordinated(file.to_owned())), | 627 | |file| Ok(Coordinated(file.to_owned())), |
| 587 | notify, | 628 | notify, |
| 588 | )?), | 629 | )?), |
| 589 | Place::Folder(_) => Ok(self | 630 | Place::Folder(_) => { |
| 590 | .notebook() | 631 | let notebook = self.notebook(); |
| 591 | .as_ref() | 632 | let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?; |
| 592 | .ok_or("A lone section has no notebook")? | 633 | let connect = |file: &Path| Ok(Coordinated(file.to_owned())); |
| 593 | .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?), | 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 | Place::Share { server, root, .. } => { | 639 | Place::Share { server, root, .. } => { |
| 595 | let file = if root.is_empty() { | 640 | let file = on_share(root, path); |
| 596 | path.to_owned() | 641 | let key = self.unlocked().get(path).cloned(); |
| 597 | } else { | 642 | // A protected section's replica is named by the identity its file holds. |
| 598 | format!("{root}/{path}") | 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 | 658 | std::fs::create_dir_all(cache.parent().ok_or("No cache")?)?; |
| 601 | .listed() | 659 | let replica = Replica::open_or_create(&cache, key.as_ref(), || { |
| 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, || { | ||
| 608 | let client = self.client().ok_or_else(|| { | 660 | let client = self.client().ok_or_else(|| { |
| 609 | std::io::Error::new( | 661 | std::io::Error::new( |
| 610 | std::io::ErrorKind::NotConnected, | 662 | std::io::ErrorKind::NotConnected, |
| ... | @@ -642,13 +694,20 @@ pub(crate) enum Status { | ... | @@ -642,13 +694,20 @@ pub(crate) enum Status { |
| 642 | pub(crate) struct Shared { | 694 | pub(crate) struct Shared { |
| 643 | pub section: session::Section, | 695 | pub section: session::Section, |
| 644 | /// Who the edits name, as OneNote names the Office user. | 696 | /// Who the edits name, as OneNote names the Office user. |
| 645 | pub author: String, | 697 | pub author: Mutex<String>, |
| 646 | pub status: AtomicU8, | 698 | pub status: AtomicU8, |
| 647 | } | 699 | } |
| 648 | 700 | ||
| 649 | impl Shared { | 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 | pub fn apply(&self, edit: Edit) -> Result<()> { | 709 | pub fn apply(&self, edit: Edit) -> Result<()> { |
| 651 | self.section.apply(&self.author, edit)?; | 710 | self.section.apply(&self.author(), edit)?; |
| 652 | self.status.store(Status::Saving as u8, Ordering::Relaxed); | 711 | self.status.store(Status::Saving as u8, Ordering::Relaxed); |
| 653 | Ok(()) | 712 | Ok(()) |
| 654 | } | 713 | } |
| ... | @@ -694,7 +753,7 @@ pub(crate) struct Version { | ... | @@ -694,7 +753,7 @@ pub(crate) struct Version { |
| 694 | pub(crate) created: Option<i64>, | 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 | (filetime / 10_000_000) as i64 - 11_644_473_600 | 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,7 +768,7 @@ pub(crate) fn filetime() -> u64 { |
| 709 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats | 768 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats |
| 710 | /// them: the long date and the short time; titled in the Default font's face. | 769 | /// them: the long date and the short time; titled in the Default font's face. |
| 711 | fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { | 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 | Ok(PageCreation::new(None, Some(""), author)? | 772 | Ok(PageCreation::new(None, Some(""), author)? |
| 714 | .titled_in(&font.face, font.color)? | 773 | .titled_in(&font.face, font.color)? |
| 715 | .dated(date, time)?) | 774 | .dated(date, time)?) |
| ... | @@ -718,12 +777,8 @@ fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { | ... | @@ -718,12 +777,8 @@ fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { |
| 718 | impl Section { | 777 | impl Section { |
| 719 | /// The notebook's themes; none for a lone section. | 778 | /// The notebook's themes; none for a lone section. |
| 720 | pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { | 779 | pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { |
| 721 | let notebook = self | 780 | self.library |
| 722 | .library | 781 | .notebook() |
| 723 | .notebook | ||
| 724 | .lock() | ||
| 725 | .unwrap_or_else(|error| error.into_inner()); | ||
| 726 | notebook | ||
| 727 | .as_ref() | 782 | .as_ref() |
| 728 | .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) | 783 | .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) |
| 729 | .unwrap_or_default() | 784 | .unwrap_or_default() |
| ... | @@ -731,12 +786,8 @@ impl Section { | ... | @@ -731,12 +786,8 @@ impl Section { |
| 731 | 786 | ||
| 732 | /// Merges `change` into the notebook's themes. | 787 | /// Merges `change` into the notebook's themes. |
| 733 | pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { | 788 | pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { |
| 734 | let notebook = self | 789 | self.library |
| 735 | .library | 790 | .notebook() |
| 736 | .notebook | ||
| 737 | .lock() | ||
| 738 | .unwrap_or_else(|error| error.into_inner()); | ||
| 739 | notebook | ||
| 740 | .as_ref() | 791 | .as_ref() |
| 741 | .ok_or("A section on its own keeps no themes")? | 792 | .ok_or("A section on its own keeps no themes")? |
| 742 | .save_themes(change)?; | 793 | .save_themes(change)?; |
| ... | @@ -772,7 +823,7 @@ impl Section { | ... | @@ -772,7 +823,7 @@ impl Section { |
| 772 | ) -> Result<Self> { | 823 | ) -> Result<Self> { |
| 773 | let shared = Arc::new(Shared { | 824 | let shared = Arc::new(Shared { |
| 774 | section: library.section(path, notify)?, | 825 | section: library.section(path, notify)?, |
| 775 | author, | 826 | author: Mutex::new(author), |
| 776 | status: AtomicU8::new(0), | 827 | status: AtomicU8::new(0), |
| 777 | }); | 828 | }); |
| 778 | let mut open = library | 829 | let mut open = library |
| ... | @@ -792,8 +843,7 @@ impl Section { | ... | @@ -792,8 +843,7 @@ impl Section { |
| 792 | .into_iter() | 843 | .into_iter() |
| 793 | .map(|(space, title, level)| Row { | 844 | .map(|(space, title, level)| Row { |
| 794 | id: space.to_string(), | 845 | id: space.to_string(), |
| 795 | // Titles keep OneNote's line breaks, which a one-line list shows as spaces. | 846 | title: one_line(&title), |
| 796 | title: title.replace(|char: char| char.is_control(), " "), | ||
| 797 | level, | 847 | level, |
| 798 | versions: conflicts | 848 | versions: conflicts |
| 799 | .iter() | 849 | .iter() |
| ... | @@ -821,7 +871,7 @@ impl Section { | ... | @@ -821,7 +871,7 @@ impl Section { |
| 821 | date: &str, | 871 | date: &str, |
| 822 | time: &str, | 872 | time: &str, |
| 823 | ) -> Result<ExGuid> { | 873 | ) -> Result<ExGuid> { |
| 824 | let creation = dated(&self.shared.author, date, time)?; | 874 | let creation = dated(&self.shared.author(), date, time)?; |
| 825 | let space = creation.space(); | 875 | let space = creation.space(); |
| 826 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; | 876 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; |
| 827 | if let Some(parent) = parent { | 877 | if let Some(parent) = parent { |
| ... | @@ -857,14 +907,14 @@ impl Section { | ... | @@ -857,14 +907,14 @@ impl Section { |
| 857 | if listed.iter().any(|(page, ..)| *page == space) { | 907 | if listed.iter().any(|(page, ..)| *page == space) { |
| 858 | if self.library.notebook().is_some() { | 908 | if self.library.notebook().is_some() { |
| 859 | let page = section.page(space)?; | 909 | let page = section.page(space)?; |
| 860 | let author = &self.shared.author; | 910 | let author = self.shared.author(); |
| 861 | self.library.with_notebook(true, |notebook| { | 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 | if listed.len() == 1 { | 915 | if listed.len() == 1 { |
| 866 | ops.push(Op::Section(SectionOp::Create(dated( | 916 | ops.push(Op::Section(SectionOp::Create(dated( |
| 867 | &self.shared.author, | 917 | &self.shared.author(), |
| 868 | date, | 918 | date, |
| 869 | time, | 919 | time, |
| 870 | )?))); | 920 | )?))); |
| ... | @@ -958,7 +1008,7 @@ impl Library { | ... | @@ -958,7 +1008,7 @@ impl Library { |
| 958 | .map(|found| Found { | 1008 | .map(|found| Found { |
| 959 | section: found.section, | 1009 | section: found.section, |
| 960 | page: found.space.to_string(), | 1010 | page: found.space.to_string(), |
| 961 | title: found.title.replace(|char: char| char.is_control(), " "), | 1011 | title: one_line(&found.title), |
| 962 | snippet: found.snippet, | 1012 | snippet: found.snippet, |
| 963 | }) | 1013 | }) |
| 964 | .collect() | 1014 | .collect() |
| ... | @@ -975,7 +1025,7 @@ impl Library { | ... | @@ -975,7 +1025,7 @@ impl Library { |
| 975 | .map(|tagged: Tagged| Tag { | 1025 | .map(|tagged: Tagged| Tag { |
| 976 | section: tagged.section, | 1026 | section: tagged.section, |
| 977 | page: tagged.space.to_string(), | 1027 | page: tagged.space.to_string(), |
| 978 | title: tagged.title.replace(|char: char| char.is_control(), " "), | 1028 | title: one_line(&tagged.title), |
| 979 | paragraph: tagged.paragraph.to_string(), | 1029 | paragraph: tagged.paragraph.to_string(), |
| 980 | name: tagged.name, | 1030 | name: tagged.name, |
| 981 | shape: tagged.shape, | 1031 | shape: tagged.shape, |
| ... | @@ -1169,11 +1219,7 @@ impl Library { | ... | @@ -1169,11 +1219,7 @@ impl Library { |
| 1169 | let (stamp, read): (Vec<u8>, Reader) = match &self.place { | 1219 | let (stamp, read): (Vec<u8>, Reader) = match &self.place { |
| 1170 | Place::Share { root, .. } => { | 1220 | Place::Share { root, .. } => { |
| 1171 | let client = self.client().ok_or("Not a share")?; | 1221 | let client = self.client().ok_or("Not a share")?; |
| 1172 | let file = if root.is_empty() { | 1222 | let file = on_share(root, path); |
| 1173 | path.to_owned() | ||
| 1174 | } else { | ||
| 1175 | format!("{root}/{path}") | ||
| 1176 | }; | ||
| 1177 | let stamp = client.stamp(&file)?; | 1223 | let stamp = client.stamp(&file)?; |
| 1178 | let mut key = stamp.header.to_vec(); | 1224 | let mut key = stamp.header.to_vec(); |
| 1179 | key.extend(stamp.length.to_le_bytes()); | 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,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 | pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char { | 1299 | pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char { |
| 1244 | report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) | 1300 | report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) |
| 1245 | .map_or(std::ptr::null_mut(), owned) | 1301 | .map_or(std::ptr::null_mut(), owned) |
| ... | @@ -1249,10 +1305,6 @@ pub(crate) fn optional(text: *const c_char) -> Option<String> { | ... | @@ -1249,10 +1305,6 @@ pub(crate) fn optional(text: *const c_char) -> Option<String> { |
| 1249 | (!text.is_null()).then(|| string(text)) | 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 | /// The notebook folder or lone section file at `path`, its section replicas kept in the | 1308 | /// The notebook folder or lone section file at `path`, its section replicas kept in the |
| 1257 | /// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this | 1309 | /// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this |
| 1258 | /// device has the host report its changes to `sb_library_touched`; any other is checked | 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,13 +1319,10 @@ pub unsafe extern "C" fn sb_library_open( |
| 1267 | local: bool, | 1319 | local: bool, |
| 1268 | error: *mut *mut c_char, | 1320 | error: *mut *mut c_char, |
| 1269 | ) -> *const Library { | 1321 | ) -> *const Library { |
| 1270 | match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) { | 1322 | shared( |
| 1271 | Ok(library) => Arc::into_raw(Arc::new(library)), | 1323 | Library::open(Path::new(&string(path)), Path::new(&string(cache)), local), |
| 1272 | Err(cause) => { | 1324 | error, |
| 1273 | failed(cause, error); | 1325 | ) |
| 1274 | std::ptr::null() | ||
| 1275 | } | ||
| 1276 | } | ||
| 1277 | } | 1326 | } |
| 1278 | 1327 | ||
| 1279 | /// The notebook folder `root`, `/`-separated from the top of `share` on the server at | 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,13 +1344,10 @@ pub unsafe extern "C" fn sb_library_server( |
| 1295 | error: *mut *mut c_char, | 1344 | error: *mut *mut c_char, |
| 1296 | ) -> *const Library { | 1345 | ) -> *const Library { |
| 1297 | let server = server(address, share, user, password, domain); | 1346 | let server = server(address, share, user, password, domain); |
| 1298 | match Library::server(server, &string(root), Path::new(&string(cache))) { | 1347 | shared( |
| 1299 | Ok(library) => Arc::into_raw(Arc::new(library)), | 1348 | Library::server(server, &string(root), Path::new(&string(cache))), |
| 1300 | Err(cause) => { | 1349 | error, |
| 1301 | failed(cause, error); | 1350 | ) |
| 1302 | std::ptr::null() | ||
| 1303 | } | ||
| 1304 | } | ||
| 1305 | } | 1351 | } |
| 1306 | 1352 | ||
| 1307 | /// Creates the notebook folder `path` holding one section with one page titled with `date` | 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,9 +1465,32 @@ pub unsafe extern "C" fn sb_library_tagged( |
| 1419 | json(library.tagged(open.map(|section| (path.as_str(), section)))) | 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 | 1468 | /// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's |
| 1423 | /// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced` | 1469 | /// Protected Section dialog does; false for a wrong password. The key stays in memory until |
| 1424 | /// (seconds since 1970, or null before the file was reached), `queued` changes and `error`. | 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 | #[unsafe(no_mangle)] | 1494 | #[unsafe(no_mangle)] |
| 1426 | pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { | 1495 | pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { |
| 1427 | json(Ok(library.sync_status())) | 1496 | json(Ok(library.sync_status())) |
| ... | @@ -1735,8 +1804,11 @@ pub unsafe extern "C" fn sb_section_delete_page( | ... | @@ -1735,8 +1804,11 @@ pub unsafe extern "C" fn sb_section_delete_page( |
| 1735 | date: *const c_char, | 1804 | date: *const c_char, |
| 1736 | time: *const c_char, | 1805 | time: *const c_char, |
| 1737 | ) -> bool { | 1806 | ) -> bool { |
| 1738 | report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time)))) | 1807 | let deleted = string(id) |
| 1739 | .is_some() | 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 | /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be | 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,6 +1818,19 @@ pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool { |
| 1746 | section.flush(Duration::from_secs_f64(seconds)) | 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 | /// Asks the section to look for changes made elsewhere now, as when the app returns. | 1834 | /// Asks the section to look for changes made elsewhere now, as when the app returns. |
| 1750 | #[unsafe(no_mangle)] | 1835 | #[unsafe(no_mangle)] |
| 1751 | pub extern "C" fn sb_section_wake(section: &Section) { | 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,7 +697,31 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { |
| 697 | section.shared.apply(edit).unwrap(); | 697 | section.shared.apply(edit).unwrap(); |
| 698 | let stored = section.shared.page(canvas.space).unwrap().0.date_text(); | 698 | let stored = section.shared.page(canvas.space).unwrap().0.date_text(); |
| 699 | assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026"); | 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 | /// A notebook copy with its library, and a section of it open for editing. | 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,7 +6,6 @@ publish = false |
| 6 | 6 | ||
| 7 | [features] | 7 | [features] |
| 8 | smb = ["dep:smb2", "dep:tokio"] | 8 | smb = ["dep:smb2", "dep:tokio"] |
| 9 | protected = ["onestore/protected", "dep:zeroize"] | ||
| 10 | 9 | ||
| 11 | [dependencies] | 10 | [dependencies] |
| 12 | onestore = { path = "../onestore" } | 11 | onestore = { path = "../onestore" } |
| ... | @@ -18,7 +17,14 @@ tempfile = "3" | ... | @@ -18,7 +17,14 @@ tempfile = "3" |
| 18 | sha2 = "0.11" | 17 | sha2 = "0.11" |
| 19 | smb2 = { version = "=0.21.0", optional = true } | 18 | smb2 = { version = "=0.21.0", optional = true } |
| 20 | tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } | 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 | [target.'cfg(target_vendor = "apple")'.dependencies] | 29 | [target.'cfg(target_vendor = "apple")'.dependencies] |
| 24 | nix = { version = "0.31", default-features = false, features = ["fs"] } | 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,7 +196,9 @@ or worker panic. Dropping it requests cancellation without waiting. Remote |
| 196 | operations and callbacks must have bounded execution times if shutdown latency | 196 | operations and callbacks must have bounded execution times if shutdown latency |
| 197 | matters. Credentials belong to the factory, not the cache database. | 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 | the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL | 202 | the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL |
| 201 | synchronization and fullfsync, each queried back: every commit is durable, and the only | 203 | synchronization and fullfsync, each queried back: every commit is durable, and the only |
| 202 | file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint | 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,7 +326,9 @@ identities, titles, dates and creation times, as OneNote does |
| 324 | (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as | 326 | (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as |
| 325 | OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is | 327 | OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is |
| 326 | placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an | 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 | lists for a file gone from its folder gives way to the file an edit gives its name. | 332 | lists for a file gone from its folder gives way to the file an edit gives its name. |
| 329 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps | 333 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps |
| 330 | the numbers. Every created, renamed or moved file is placed with | 334 | the numbers. Every created, renamed or moved file is placed with |
| ... | @@ -346,13 +350,34 @@ targets are `None`. | ... | @@ -346,13 +350,34 @@ targets are `None`. |
| 346 | replica, and `session::stored_pages(image)` builds its pages with each page's | 350 | replica, and `session::stored_pages(image)` builds its pages with each page's |
| 347 | `LastModifiedTime`: the app's search indexes the sections it has not opened this way. | 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)` | 353 | A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens |
| 350 | reads a `Locked` section as `Unlocked { pages, .. }`, and | 354 | its key (a wrong password is `Error::Protected(PasswordMismatch)`), and |
| 351 | `Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)` | 355 | `section_unlocked(path, &key, notify)` (`section_unlocked_with`, or |
| 352 | applies page ops and stores them under the section's key, straight to the file: | 356 | `Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself) |
| 353 | nothing of a protected section is cached or queued, a section written since | 357 | opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do, |
| 354 | `unlocked` was read refuses the edit, and a wrong password is | 358 | each revision sealed under the section's key. The replica keeps none of it in the clear: |
| 355 | `Error::Protected(PasswordMismatch)`. | 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 | `Section::import_page(page, author)` copies a page, usually read from another | 382 | `Section::import_page(page, author)` copies a page, usually read from another |
| 358 | section, to the end of this one as one `SectionOp::Import` queued like the | 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,12 +394,27 @@ is `Reordered`. A failed read returns the error and keeps |
| 369 | the previous catalog, so an unreachable share never reads as an emptied | 394 | the previous catalog, so an unreachable share never reads as an emptied |
| 370 | notebook. | 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 | ## Snowbound's folder | 412 | ## Snowbound's folder |
| 373 | 413 | ||
| 374 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which | 414 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which |
| 375 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it | 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 | 416 | on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden |
| 377 | dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's | 417 | without storing it), `SetFileAttributesW` on Windows, or macOS's |
| 378 | `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook | 418 | `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook |
| 379 | without the folder is whole. | 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,8 +515,7 @@ probing the connection, so an idle watch sends nothing. `changed` hears each bat |
| 475 | paths relative to the directory, `""` when the server lost count, and an error when the watch | 515 | paths relative to the directory, `""` when the server lost count, and an error when the watch |
| 476 | ends with its connection. | 516 | ends with its connection. |
| 477 | 517 | ||
| 478 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | 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, |
| 479 | share handle that excludes writes and deletion. Empty files succeed; limits, | ||
| 480 | interrupted reads and failed close never return partial bytes. This payload read | 519 | interrupted reads and failed close never return partial bytes. This payload read |
| 481 | does not parse a OneStore header or acquire its reader-coordination bytes. | 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,7 +39,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 39 | let bytes = onestore::read_file(&source_path)?; | 39 | let bytes = onestore::read_file(&source_path)?; |
| 40 | let store = Store::parse(&bytes)?; | 40 | let store = Store::parse(&bytes)?; |
| 41 | let index = RevisionIndex::parse(&store)?; | 41 | let index = RevisionIndex::parse(&store)?; |
| 42 | #[cfg(feature = "protected")] | ||
| 43 | let unlocked = if let Some(path) = &password_file { | 42 | let unlocked = if let Some(path) = &password_file { |
| 44 | use std::io::Read; | 43 | use std::io::Read; |
| 45 | let mut password = zeroize::Zeroizing::new(String::new()); | 44 | let mut password = zeroize::Zeroizing::new(String::new()); |
| ... | @@ -57,20 +56,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -57,20 +56,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 57 | } else { | 56 | } else { |
| 58 | None | 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 | let document = match &unlocked { | 59 | let document = match &unlocked { |
| 69 | Some(section) => section.document()?, | 60 | Some(section) => section.document()?, |
| 70 | None => Document::parse(&index)?, | 61 | None => Document::parse(&index)?, |
| 71 | }; | 62 | }; |
| 72 | #[cfg(not(feature = "protected"))] | ||
| 73 | let document = Document::parse(&index)?; | ||
| 74 | if let Some(destination) = destination { | 63 | if let Some(destination) = destination { |
| 75 | #[cfg(unix)] | 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,6 +135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 135 | identity: None, | 135 | identity: None, |
| 136 | created: None, | 136 | created: None, |
| 137 | margin_origin: [0.0; 2], | 137 | margin_origin: [0.0; 2], |
| 138 | rtl: false, | ||
| 138 | color: None, | 139 | color: None, |
| 139 | rule_lines: None, | 140 | rule_lines: None, |
| 140 | objects: vec![PageObject::Outline(Outline { | 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,6 +44,7 @@ fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> { |
| 44 | identity: None, | 44 | identity: None, |
| 45 | created: None, | 45 | created: None, |
| 46 | margin_origin: [0.0; 2], | 46 | margin_origin: [0.0; 2], |
| 47 | rtl: false, | ||
| 47 | color: None, | 48 | color: None, |
| 48 | rule_lines: None, | 49 | rule_lines: None, |
| 49 | objects: vec![PageObject::Outline(Outline { | 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,6 +50,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 50 | identity: None, | 50 | identity: None, |
| 51 | created: None, | 51 | created: None, |
| 52 | margin_origin: [0.0; 2], | 52 | margin_origin: [0.0; 2], |
| 53 | rtl: false, | ||
| 53 | color: None, | 54 | color: None, |
| 54 | rule_lines: None, | 55 | rule_lines: None, |
| 55 | objects: vec![PageObject::Outline(Outline { | 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,8 +30,6 @@ const STAGGER: Duration = Duration::from_millis(100); |
| 30 | /// How long a reported change settles before its section is checked, so the writes of one | 30 | /// How long a reported change settles before its section is checked, so the writes of one |
| 31 | /// commit cost one check. | 31 | /// commit cost one check. |
| 32 | const SETTLE: Duration = Duration::from_secs(1); | 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 | /// Keeps each watched section in sync: a section is checked when a watch on the notebook's | 34 | /// Keeps each watched section in sync: a section is checked when a watch on the notebook's |
| 37 | /// folder reports it changed, when it could not be reached, and otherwise every interval. | 35 | /// folder reports it changed, when it could not be reached, and otherwise every interval. |
| ... | @@ -66,6 +64,7 @@ pub(crate) struct Shared { | ... | @@ -66,6 +64,7 @@ pub(crate) struct Shared { |
| 66 | } | 64 | } |
| 67 | 65 | ||
| 68 | /// A connection's report of its notebook folder's changes, from a watch armed on connecting. | 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 | pub(crate) struct Reports { | 68 | pub(crate) struct Reports { |
| 70 | shared: Weak<Shared>, | 69 | shared: Weak<Shared>, |
| 71 | connection: u64, | 70 | connection: u64, |
| ... | @@ -338,7 +337,8 @@ impl Background { | ... | @@ -338,7 +337,8 @@ impl Background { |
| 338 | let root = root.clone(); | 337 | let root = root.clone(); |
| 339 | let list = move |folder: &str| { | 338 | let list = move |folder: &str| { |
| 340 | use crate::discover::Source; | 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 | Ok(((bind, list), reported)) | 343 | Ok(((bind, list), reported)) |
| 344 | }, | 344 | }, |
| ... | @@ -361,6 +361,7 @@ impl Background { | ... | @@ -361,6 +361,7 @@ impl Background { |
| 361 | pub fn hold(&self, path: &str, section: &Section) { | 361 | pub fn hold(&self, path: &str, section: &Section) { |
| 362 | let replica = section.replica(); | 362 | let replica = section.replica(); |
| 363 | let Some(worker) = replica | 363 | let Some(worker) = replica |
| 364 | .section | ||
| 364 | .worker | 365 | .worker |
| 365 | .lock() | 366 | .lock() |
| 366 | .ok() | 367 | .ok() |
| ... | @@ -487,6 +488,7 @@ impl Shared { | ... | @@ -487,6 +488,7 @@ impl Shared { |
| 487 | } | 488 | } |
| 488 | } | 489 | } |
| 489 | 490 | ||
| 491 | #[cfg_attr(not(feature = "smb"), allow(dead_code))] | ||
| 490 | impl Reports { | 492 | impl Reports { |
| 491 | /// The sections at or below these paths changed. | 493 | /// The sections at or below these paths changed. |
| 492 | pub(crate) fn touched(&self, paths: &[String]) { | 494 | pub(crate) fn touched(&self, paths: &[String]) { |
| ... | @@ -741,7 +743,10 @@ fn list( | ... | @@ -741,7 +743,10 @@ fn list( |
| 741 | 743 | ||
| 742 | /// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it. | 744 | /// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it. |
| 743 | fn discard(replica: &Path) { | 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 | for suffix in ["-wal", "-shm", ""] { | 750 | for suffix in ["-wal", "-shm", ""] { |
| 746 | let mut file = replica.as_os_str().to_owned(); | 751 | let mut file = replica.as_os_str().to_owned(); |
| 747 | file.push(suffix); | 752 | file.push(suffix); |
| ... | @@ -837,7 +842,7 @@ fn step<R: Remote>( | ... | @@ -837,7 +842,7 @@ fn step<R: Remote>( |
| 837 | if let Some(folder) = replica.parent() { | 842 | if let Some(folder) = replica.parent() { |
| 838 | std::fs::create_dir_all(folder)?; | 843 | std::fs::create_dir_all(folder)?; |
| 839 | } | 844 | } |
| 840 | Replica::seed(replica, &image)?; | 845 | Replica::seed(replica, &image, None)?; |
| 841 | Ok(Stamp::of(&image)?) | 846 | Ok(Stamp::of(&image)?) |
| 842 | })(); | 847 | })(); |
| 843 | return match copied { | 848 | return match copied { |
| ... | @@ -854,7 +859,7 @@ fn step<R: Remote>( | ... | @@ -854,7 +859,7 @@ fn step<R: Remote>( |
| 854 | Ok(versions) => versions, | 859 | Ok(versions) => versions, |
| 855 | Err(error) => return (None, Err(Error::RemoteIo(error))), | 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 | Ok((base, 0)) if base == stamp && versions.is_empty() => { | 863 | Ok((base, 0)) if base == stamp && versions.is_empty() => { |
| 859 | return (Some(0), Ok((stamp, moved))); | 864 | return (Some(0), Ok((stamp, moved))); |
| 860 | } | 865 | } |
crates/notebook/src/base.rs+14| ... | @@ -28,6 +28,20 @@ fn damaged() -> crate::Error { | ... | @@ -28,6 +28,20 @@ fn damaged() -> crate::Error { |
| 28 | io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image").into() | 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 | /// The whole image, or `None` when none is stored. | 45 | /// The whole image, or `None` when none is stored. |
| 32 | pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> { | 46 | pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> { |
| 33 | let mut query = connection.prepare_cached(&format!( | 47 | let mut query = connection.prepare_cached(&format!( |
crates/notebook/src/discover.rs+40-19| ... | @@ -50,10 +50,30 @@ pub struct Folder { | ... | @@ -50,10 +50,30 @@ pub struct Folder { |
| 50 | pub toc: Option<Toc>, | 50 | pub toc: Option<Toc>, |
| 51 | pub sections: Vec<Section>, | 51 | pub sections: Vec<Section>, |
| 52 | pub groups: Vec<Folder>, | 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 | /// Child section files and groups that could not be read. | 56 | /// Child section files and groups that could not be read. |
| 54 | pub unavailable: Vec<Unavailable>, | 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 | /// A section file or group folder denied or gone while listing; each discovery tries it again. | 77 | /// A section file or group folder denied or gone while listing; each discovery tries it again. |
| 58 | #[derive(Debug, Serialize)] | 78 | #[derive(Debug, Serialize)] |
| 59 | pub struct Unavailable { | 79 | pub struct Unavailable { |
| ... | @@ -346,6 +366,7 @@ fn scan( | ... | @@ -346,6 +366,7 @@ fn scan( |
| 346 | toc: None, | 366 | toc: None, |
| 347 | sections: Vec::new(), | 367 | sections: Vec::new(), |
| 348 | groups: Vec::new(), | 368 | groups: Vec::new(), |
| 369 | order: Vec::new(), | ||
| 349 | unavailable: Vec::new(), | 370 | unavailable: Vec::new(), |
| 350 | }; | 371 | }; |
| 351 | for entry in &listing { | 372 | for entry in &listing { |
| ... | @@ -551,26 +572,26 @@ fn scan( | ... | @@ -551,26 +572,26 @@ fn scan( |
| 551 | .flat_map(|toc| &toc.unresolved) | 572 | .flat_map(|toc| &toc.unresolved) |
| 552 | .map(|entry| (entry.file, entry.order)) | 573 | .map(|entry| (entry.file, entry.order)) |
| 553 | .collect(); | 574 | .collect(); |
| 554 | result.sections.sort_by(|a, b| { | 575 | let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied()); |
| 555 | (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path) | 576 | let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone()); |
| 556 | .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path)) | 577 | result |
| 557 | }); | 578 | .sections |
| 558 | result.groups.sort_by(|a, b| { | 579 | .sort_by_cached_key(|section| ranked(Some(section.file_id), &section.path)); |
| 559 | ( | 580 | let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id); |
| 560 | a.toc | 581 | result |
| 561 | .as_ref() | 582 | .groups |
| 562 | .and_then(|toc| order.get(&toc.file_id).copied()) | 583 | .sort_by_cached_key(|group| ranked(group_file(group), &group.path)); |
| 563 | .unwrap_or(u32::MAX), | 584 | let mut entries: Vec<(u32, String)> = (result.sections.iter()) |
| 564 | &a.path, | 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(&( | 592 | .collect(); |
| 567 | b.toc | 593 | entries.sort(); |
| 568 | .as_ref() | 594 | result.order = entries.into_iter().map(|(_, path)| path).collect(); |
| 569 | .and_then(|toc| order.get(&toc.file_id).copied()) | ||
| 570 | .unwrap_or(u32::MAX), | ||
| 571 | &b.path, | ||
| 572 | )) | ||
| 573 | }); | ||
| 574 | let listed = |file: [u8; 16], path: &str| { | 595 | let listed = |file: [u8; 16], path: &str| { |
| 575 | let name = path.rsplit('/').next().unwrap_or(path); | 596 | let name = path.rsplit('/').next().unwrap_or(path); |
| 576 | result | 597 | result |
crates/notebook/src/discover/source.rs+5-1| ... | @@ -194,6 +194,10 @@ impl Source for Smb<'_> { | ... | @@ -194,6 +194,10 @@ impl Source for Smb<'_> { |
| 194 | return None; | 194 | return None; |
| 195 | } | 195 | } |
| 196 | let stamp = self.client.stamp(&self.path(path).ok()?).ok()?; | 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,6 +10,7 @@ use onestore::{ |
| 10 | ExGuid, | 10 | ExGuid, |
| 11 | op::{Edit, OpError}, | 11 | op::{Edit, OpError}, |
| 12 | page::Page, | 12 | page::Page, |
| 13 | protected::Key, | ||
| 13 | }; | 14 | }; |
| 14 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; | 15 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; |
| 15 | use std::{ | 16 | use std::{ |
| ... | @@ -26,6 +27,7 @@ mod base; | ... | @@ -26,6 +27,7 @@ mod base; |
| 26 | pub mod location; | 27 | pub mod location; |
| 27 | mod merge; | 28 | mod merge; |
| 28 | mod migrate; | 29 | mod migrate; |
| 30 | pub mod package; | ||
| 29 | mod queue; | 31 | mod queue; |
| 30 | mod recovery; | 32 | mod recovery; |
| 31 | mod resolve; | 33 | mod resolve; |
| ... | @@ -61,7 +63,7 @@ pub enum Error { | ... | @@ -61,7 +63,7 @@ pub enum Error { |
| 61 | Rejected(#[from] OpError), | 63 | Rejected(#[from] OpError), |
| 62 | #[error("External payload identity now refers to different bytes")] | 64 | #[error("External payload identity now refers to different bytes")] |
| 63 | AssetChanged, | 65 | AssetChanged, |
| 64 | #[cfg(feature = "protected")] | 66 | /// A password-protected section's password did not match, or its format is unknown. |
| 65 | #[error(transparent)] | 67 | #[error(transparent)] |
| 66 | Protected(#[from] onestore::protected::Error), | 68 | Protected(#[from] onestore::protected::Error), |
| 67 | } | 69 | } |
| ... | @@ -98,10 +100,8 @@ pub enum Resolution { | ... | @@ -98,10 +100,8 @@ pub enum Resolution { |
| 98 | /// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's | 100 | /// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's |
| 99 | /// exclusive connection retains ownership between local transactions. | 101 | /// exclusive connection retains ownership between local transactions. |
| 100 | pub struct Replica { | 102 | pub struct Replica { |
| 101 | connection: Arc<Mutex<Connection>>, | ||
| 102 | section: Arc<working::Thread>, | 103 | section: Arc<working::Thread>, |
| 103 | synchronization: Mutex<()>, | 104 | synchronization: Mutex<()>, |
| 104 | worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>, | ||
| 105 | /// The section's root object space, which names the document. | 105 | /// The section's root object space, which names the document. |
| 106 | root: ExGuid, | 106 | root: ExGuid, |
| 107 | /// Last, so that it runs once the fields above have closed the cache. | 107 | /// Last, so that it runs once the fields above have closed the cache. |
| ... | @@ -124,30 +124,31 @@ impl Replica { | ... | @@ -124,30 +124,31 @@ impl Replica { |
| 124 | /// Seeds a new cache from a validated section image, refusing any existing path. | 124 | /// Seeds a new cache from a validated section image, refusing any existing path. |
| 125 | /// An initialization error preserves the created file for inspection. | 125 | /// An initialization error preserves the created file for inspection. |
| 126 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { | 126 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { |
| 127 | Self::seed(path.as_ref(), source)?; | 127 | Self::seed(path.as_ref(), source, None)?; |
| 128 | Self::start(cache_connection(path.as_ref())?) | 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 | 131 | /// Opens the cache at `path`, a protected section's under `key`, first creating it from |
| 132 | /// is none. A cache another thread creates meanwhile, as the background makes an offline | 132 | /// the image `source` reads where there is none. A cache another thread creates meanwhile, |
| 133 | /// copy, is opened instead. | 133 | /// as the background makes an offline copy, is opened instead. |
| 134 | pub fn open_or_create( | 134 | pub fn open_or_create( |
| 135 | path: impl AsRef<Path>, | 135 | path: impl AsRef<Path>, |
| 136 | key: Option<&Key>, | ||
| 136 | source: impl FnOnce() -> Result<Vec<u8>>, | 137 | source: impl FnOnce() -> Result<Vec<u8>>, |
| 137 | ) -> Result<Self> { | 138 | ) -> Result<Self> { |
| 138 | let path = path.as_ref(); | 139 | let path = path.as_ref(); |
| 139 | if !path.exists() { | 140 | if !path.exists() { |
| 140 | match Self::seed(path, &source()?) { | 141 | match Self::seed(path, &source()?, key) { |
| 141 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} | 142 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} |
| 142 | seeded => seeded?, | 143 | seeded => seeded?, |
| 143 | } | 144 | } |
| 144 | } | 145 | } |
| 145 | Self::open(path) | 146 | Self::open_with(path, key.cloned()) |
| 146 | } | 147 | } |
| 147 | 148 | ||
| 148 | /// `create` without opening the cache it made, as for an offline copy. | 149 | /// `create` without opening the cache it made, as for an offline copy. |
| 149 | pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> { | 150 | pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> { |
| 150 | validate(source)?; | 151 | validate(source, key)?; |
| 151 | // Built under a name of its own and linked into place whole, so that a cache that | 152 | // Built under a name of its own and linked into place whole, so that a cache that |
| 152 | // exists is complete, and two threads making the same one never share a build. | 153 | // exists is complete, and two threads making the same one never share a build. |
| 153 | static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | 154 | static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| ... | @@ -201,7 +202,10 @@ impl Replica { | ... | @@ -201,7 +202,10 @@ impl Replica { |
| 201 | /// a conversion that cannot reproduce every queued page leaves it untouched. | 202 | /// a conversion that cannot reproduce every queued page leaves it untouched. |
| 202 | /// Unrecognized databases and unsupported journal modes are rejected. | 203 | /// Unrecognized databases and unsupported journal modes are rejected. |
| 203 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | 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 | let mut connection = cache_connection(path)?; | 209 | let mut connection = cache_connection(path)?; |
| 206 | let application: u32 = | 210 | let application: u32 = |
| 207 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; | 211 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; |
| ... | @@ -233,25 +237,21 @@ impl Replica { | ... | @@ -233,25 +237,21 @@ impl Replica { |
| 233 | } | 237 | } |
| 234 | } | 238 | } |
| 235 | write_ahead(&connection)?; | 239 | write_ahead(&connection)?; |
| 236 | Self::start(connection) | 240 | Self::start(connection, key) |
| 237 | } | 241 | } |
| 238 | 242 | ||
| 239 | fn start(connection: Connection) -> Result<Self> { | 243 | fn start(connection: Connection, key: Option<Key>) -> Result<Self> { |
| 240 | let connection = Arc::new(Mutex::new(connection)); | 244 | let (section, root) = working::spawn(connection, key)?; |
| 241 | let worker = Arc::new(Mutex::new(std::sync::Weak::new())); | ||
| 242 | let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; | ||
| 243 | Ok(Self { | 245 | Ok(Self { |
| 244 | connection, | ||
| 245 | section, | 246 | section, |
| 246 | synchronization: Mutex::new(()), | 247 | synchronization: Mutex::new(()), |
| 247 | worker, | ||
| 248 | root, | 248 | root, |
| 249 | released: Released::default(), | 249 | released: Released::default(), |
| 250 | }) | 250 | }) |
| 251 | } | 251 | } |
| 252 | 252 | ||
| 253 | fn lock(&self) -> Result<MutexGuard<'_, Connection>> { | 253 | fn lock(&self) -> Result<MutexGuard<'_, Connection>> { |
| 254 | lock(&self.connection) | 254 | lock(&self.section.connection) |
| 255 | } | 255 | } |
| 256 | 256 | ||
| 257 | /// Hands `request` to the section thread. | 257 | /// Hands `request` to the section thread. |
| ... | @@ -329,9 +329,9 @@ impl Replica { | ... | @@ -329,9 +329,9 @@ impl Replica { |
| 329 | 329 | ||
| 330 | /// The section image the queued edits leave, the unsealed ones sealed as one more | 330 | /// The section image the queued edits leave, the unsealed ones sealed as one more |
| 331 | /// revision whose identities differ per call: O(section). | 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 | self.written()?; | 333 | self.written()?; |
| 334 | working::image(&*self.lock()?) | 334 | working::image(&*self.lock()?, self.section.key.as_ref()) |
| 335 | } | 335 | } |
| 336 | 336 | ||
| 337 | /// Waits until the edits applied before it are written to the queue, as reads answer | 337 | /// Waits until the edits applied before it are written to the queue, as reads answer |
| ... | @@ -342,18 +342,51 @@ impl Replica { | ... | @@ -342,18 +342,51 @@ impl Replica { |
| 342 | 342 | ||
| 343 | /// The section file's identity, which internal links name as `section-id`. | 343 | /// The section file's identity, which internal links name as `section-id`. |
| 344 | pub fn identity(&self) -> Result<[u8; 16]> { | 344 | pub fn identity(&self) -> Result<[u8; 16]> { |
| 345 | let stamp = base::stamp(&*self.lock()?, base::Image::Base)? | 345 | let stamp = base::base_stamp(&*self.lock()?)?; |
| 346 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?; | ||
| 347 | Ok(onestore::Header::parse(&stamp.header)?.file_id) | 346 | Ok(onestore::Header::parse(&stamp.header)?.file_id) |
| 348 | } | 347 | } |
| 349 | 348 | ||
| 350 | /// Queued edits, oldest first. | 349 | /// Queued edits, oldest first. |
| 351 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | 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 | fn wake_sync(&self) { | 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,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. | 422 | /// Fully validates a section image, a protected one under `key`, returning its root object |
| 390 | fn validate(source: &[u8]) -> Result<ExGuid> { | 423 | /// space. |
| 424 | fn validate(source: &[u8], key: Option<&Key>) -> Result<ExGuid> { | ||
| 391 | let arena = onestore::Arena::default(); | 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 | /// A closed cache, without opening the replica. | 432 | /// A closed cache, without opening the replica. |
| ... | @@ -405,25 +442,14 @@ fn closed(path: &Path) -> Result<Connection> { | ... | @@ -405,25 +442,14 @@ fn closed(path: &Path) -> Result<Connection> { |
| 405 | } | 442 | } |
| 406 | 443 | ||
| 407 | /// A closed cache's base stamp and how many edits wait. | 444 | /// A closed cache's base stamp and how many edits wait. |
| 408 | fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { | 445 | fn peek(connection: &Connection) -> Result<(onestore::Stamp, u64)> { |
| 409 | let connection = closed(path)?; | 446 | let base = base::base_stamp(connection)?; |
| 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"))?; | ||
| 412 | let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; | 447 | let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; |
| 413 | Ok((base, unsigned(queued)?)) | 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. | 451 | fn pending(connection: &Connection, key: Option<&Key>) -> Result<Vec<PendingEdit>> { |
| 417 | fn copied(path: &Path, stamp: &onestore::Stamp) -> Result<Option<Vec<u8>>> { | 452 | queue::load(connection, key, None) |
| 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) | ||
| 427 | } | 453 | } |
| 428 | 454 | ||
| 429 | fn unsigned(value: i64) -> Result<u64> { | 455 | fn unsigned(value: i64) -> Result<u64> { |
crates/notebook/src/location.rs+1-1| ... | @@ -110,7 +110,7 @@ pub(crate) fn claim( | ... | @@ -110,7 +110,7 @@ pub(crate) fn claim( |
| 110 | if !replica.exists() { | 110 | if !replica.exists() { |
| 111 | continue; | 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 | Ok((base, _)) => { | 114 | Ok((base, _)) => { |
| 115 | legacy || stamp(identity).is_some_and(|file| follows(&file, &base)) | 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,6 +314,7 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { |
| 314 | } | 314 | } |
| 315 | queue::insert( | 315 | queue::insert( |
| 316 | transaction, | 316 | transaction, |
| 317 | None, | ||
| 317 | Some(crate::unsigned(edit.id)?), | 318 | Some(crate::unsigned(edit.id)?), |
| 318 | current, | 319 | current, |
| 319 | &edit.author, | 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,14 +1,23 @@ |
| 1 | //! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes | 1 | //! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes |
| 2 | //! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them. | 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 | use crate::{PendingEdit, Result, signed, unsigned}; | 9 | use crate::{PendingEdit, Result, signed, unsigned}; |
| 10 | use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; | ||
| 11 | use hmac::{Hmac, Mac}; | ||
| 5 | use onestore::{ | 12 | use onestore::{ |
| 6 | op::{Edit, Op, PageOp, SectionOp, TableEdit}, | 13 | op::{Edit, Op, PageOp, SectionOp, TableEdit}, |
| 7 | page::{PageObject, PageParagraph, ParagraphContent, TableCell}, | 14 | page::{PageObject, PageParagraph, ParagraphContent, TableCell}, |
| 15 | protected::Key, | ||
| 8 | }; | 16 | }; |
| 9 | use rusqlite::{Connection, OptionalExtension, params}; | 17 | use rusqlite::{Connection, OptionalExtension, params}; |
| 10 | use sha2::{Digest, Sha256}; | 18 | use sha2::{Digest, Sha256}; |
| 11 | use std::{io, sync::Arc}; | 19 | use std::{io, sync::Arc}; |
| 20 | use zeroize::Zeroizing; | ||
| 12 | 21 | ||
| 13 | type Payload = Option<Arc<[u8]>>; | 22 | type Payload = Option<Arc<[u8]>>; |
| 14 | 23 | ||
| ... | @@ -92,16 +101,79 @@ fn damaged(message: &'static str) -> crate::Error { | ... | @@ -92,16 +101,79 @@ fn damaged(message: &'static str) -> crate::Error { |
| 92 | io::Error::new(io::ErrorKind::InvalidData, message).into() | 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 | /// returns the edit's id. | 167 | /// returns the edit's id. |
| 97 | pub(crate) fn insert( | 168 | pub(crate) fn insert( |
| 98 | connection: &Connection, | 169 | connection: &Connection, |
| 170 | key: Option<&Key>, | ||
| 99 | id: Option<u64>, | 171 | id: Option<u64>, |
| 100 | batch: i64, | 172 | batch: i64, |
| 101 | author: &str, | 173 | author: &str, |
| 102 | edit: &Edit, | 174 | edit: &Edit, |
| 103 | ) -> Result<u64> { | 175 | ) -> Result<u64> { |
| 104 | let (text, names) = encode(connection, edit)?; | 176 | let (text, names) = encode(connection, key, edit)?; |
| 105 | connection.execute( | 177 | connection.execute( |
| 106 | "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)", | 178 | "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)", |
| 107 | params![id.map(signed).transpose()?, batch, author, text, names], | 179 | params![id.map(signed).transpose()?, batch, author, text, names], |
| ... | @@ -114,8 +186,13 @@ fn hex(digest: &[u8]) -> String { | ... | @@ -114,8 +186,13 @@ fn hex(digest: &[u8]) -> String { |
| 114 | } | 186 | } |
| 115 | 187 | ||
| 116 | /// Replaces a stored edit's ops. | 188 | /// Replaces a stored edit's ops. |
| 117 | pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> { | 189 | pub(crate) fn rewrite( |
| 118 | let (text, names) = encode(connection, edit)?; | 190 | connection: &Connection, |
| 191 | key: Option<&Key>, | ||
| 192 | id: u64, | ||
| 193 | edit: &Edit, | ||
| 194 | ) -> Result<()> { | ||
| 195 | let (text, names) = encode(connection, key, edit)?; | ||
| 119 | connection.execute( | 196 | connection.execute( |
| 120 | "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3", | 197 | "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3", |
| 121 | params![text, names, signed(id)?], | 198 | params![text, names, signed(id)?], |
| ... | @@ -123,26 +200,38 @@ pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<( | ... | @@ -123,26 +200,38 @@ pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<( |
| 123 | Ok(()) | 200 | Ok(()) |
| 124 | } | 201 | } |
| 125 | 202 | ||
| 126 | /// The serialized edit without payload bytes and the payload names, storing the payloads. | 203 | /// The serialized edit without payload bytes and the payload names, storing the payloads; |
| 127 | fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String>)> { | 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 | let mut edit = edit.clone(); | 210 | let mut edit = edit.clone(); |
| 129 | let mut names: Vec<Option<String>> = Vec::new(); | 211 | let mut names: Vec<Option<String>> = Vec::new(); |
| 130 | let mut failure = None; | 212 | let mut failure = None; |
| 131 | slots(&mut edit, |payload| { | 213 | slots(&mut edit, |payload| { |
| 132 | let name = payload.take().map(|bytes| { | 214 | let name = payload.take().map(|bytes| { |
| 133 | let digest = Sha256::digest(&bytes); | 215 | let name = name(key, &bytes); |
| 134 | if let Err(error) = connection.execute( | 216 | let stored = match key { |
| 135 | "INSERT INTO payloads(sha256, bytes) VALUES (?1, ?2) ON CONFLICT DO NOTHING", | 217 | Some(key) => seal(key, b"payload", &bytes), |
| 136 | params![&digest[..], &bytes[..]], | 218 | None => Ok(bytes.to_vec()), |
| 137 | ) { | 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 | failure.get_or_insert(error); | 227 | failure.get_or_insert(error); |
| 139 | } | 228 | } |
| 140 | hex(&digest) | 229 | name |
| 141 | }); | 230 | }); |
| 142 | names.push(name); | 231 | names.push(name); |
| 143 | }); | 232 | }); |
| 144 | if let Some(error) = failure { | 233 | if let Some(error) = failure { |
| 145 | return Err(error.into()); | 234 | return Err(error); |
| 146 | } | 235 | } |
| 147 | let names = names | 236 | let names = names |
| 148 | .iter() | 237 | .iter() |
| ... | @@ -150,15 +239,39 @@ fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String | ... | @@ -150,15 +239,39 @@ fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String |
| 150 | .then(|| serde_json::to_string(&names)) | 239 | .then(|| serde_json::to_string(&names)) |
| 151 | .transpose() | 240 | .transpose() |
| 152 | .map_err(io::Error::other)?; | 241 | .map_err(io::Error::other)?; |
| 153 | Ok(( | 242 | let text = Zeroizing::new(serde_json::to_string(&edit).map_err(io::Error::other)?); |
| 154 | serde_json::to_string(&edit).map_err(io::Error::other)?, | 243 | let text = match key { |
| 155 | names, | 244 | Some(key) => { |
| 156 | )) | 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> { | 253 | /// A stored edit's ops, without its payloads. |
| 160 | let mut edit: Edit = serde_json::from_str(text) | 254 | pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result<Edit> { |
| 161 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | 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 | let Some(names) = names else { | 275 | let Some(names) = names else { |
| 163 | return Ok(edit); | 276 | return Ok(edit); |
| 164 | }; | 277 | }; |
| ... | @@ -176,18 +289,24 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result< | ... | @@ -176,18 +289,24 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result< |
| 176 | let Some(name) = name else { | 289 | let Some(name) = name else { |
| 177 | return; | 290 | return; |
| 178 | }; | 291 | }; |
| 179 | match query | 292 | let bytes = query |
| 180 | .query_row([&name], |row| row.get::<_, Vec<u8>>(0)) | 293 | .query_row([&name], |row| row.get::<_, Vec<u8>>(0)) |
| 181 | .optional() | 294 | .optional(); |
| 182 | { | 295 | let bytes = match (key, bytes) { |
| 183 | Ok(Some(bytes)) if hex(&Sha256::digest(&bytes)) == name => { | 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 | *payload = Some(Arc::from(bytes)); | 303 | *payload = Some(Arc::from(bytes)); |
| 185 | } | 304 | } |
| 186 | Ok(_) => { | 305 | Ok(_) => { |
| 187 | failure.get_or_insert(damaged("A queued payload is missing or damaged")); | 306 | failure.get_or_insert(damaged("A queued payload is missing or damaged")); |
| 188 | } | 307 | } |
| 189 | Err(error) => { | 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,7 +320,11 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result< |
| 201 | } | 320 | } |
| 202 | 321 | ||
| 203 | /// Queued edits, oldest first; of one batch when `batch` is given. | 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 | let mut query = connection.prepare_cached( | 328 | let mut query = connection.prepare_cached( |
| 206 | "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id", | 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,7 +334,7 @@ pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<Pe |
| 211 | edits.push(PendingEdit { | 334 | edits.push(PendingEdit { |
| 212 | id: unsigned(row.get(0)?)?, | 335 | id: unsigned(row.get(0)?)?, |
| 213 | author: row.get(1)?, | 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 | Ok(edits) | 340 | Ok(edits) |
| ... | @@ -304,7 +427,7 @@ mod tests { | ... | @@ -304,7 +427,7 @@ mod tests { |
| 304 | }) | 427 | }) |
| 305 | .collect(), | 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 | let stored: String = connection | 431 | let stored: String = connection |
| 309 | .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| { | 432 | .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| { |
| 310 | row.get(0) | 433 | row.get(0) |
| ... | @@ -315,7 +438,7 @@ mod tests { | ... | @@ -315,7 +438,7 @@ mod tests { |
| 315 | .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) | 438 | .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) |
| 316 | .unwrap(); | 439 | .unwrap(); |
| 317 | assert_eq!(payloads, 1); | 440 | assert_eq!(payloads, 1); |
| 318 | let loaded = load(&connection, None).unwrap(); | 441 | let loaded = load(&connection, None, None).unwrap(); |
| 319 | assert_eq!(loaded[0].edit, edit); | 442 | assert_eq!(loaded[0].edit, edit); |
| 320 | let Op::Page { | 443 | let Op::Page { |
| 321 | op: | 444 | op: |
crates/notebook/src/recovery.rs+18-15| ... | @@ -21,11 +21,22 @@ pub struct RecoverySummary { | ... | @@ -21,11 +21,22 @@ pub struct RecoverySummary { |
| 21 | /// Read-only recovery evidence; it cannot publish or acknowledge an edit. | 21 | /// Read-only recovery evidence; it cannot publish or acknowledge an edit. |
| 22 | pub struct Recovery { | 22 | pub struct Recovery { |
| 23 | connection: Connection, | 23 | connection: Connection, |
| 24 | /// A password-protected section's key, which opens its sealed queue. | ||
| 25 | key: Option<Key>, | ||
| 24 | } | 26 | } |
| 25 | 27 | ||
| 26 | impl Recovery { | 28 | impl Recovery { |
| 27 | /// Opens an exported archive without migration or conversion into a writable replica. | 29 | /// Opens an exported archive without migration or conversion into a writable replica. |
| 28 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | 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 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; | 40 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; |
| 30 | connection.busy_timeout(Duration::ZERO)?; | 41 | connection.busy_timeout(Duration::ZERO)?; |
| 31 | connection.execute_batch("BEGIN")?; | 42 | connection.execute_batch("BEGIN")?; |
| ... | @@ -48,10 +59,10 @@ impl Recovery { | ... | @@ -48,10 +59,10 @@ impl Recovery { |
| 48 | ) | 59 | ) |
| 49 | .into()); | 60 | .into()); |
| 50 | } | 61 | } |
| 51 | let recovery = Self { connection }; | 62 | let recovery = Self { connection, key }; |
| 52 | recovery.snapshot()?; | 63 | recovery.snapshot()?; |
| 53 | for edit in pending(&recovery.connection)? { | 64 | for edit in recovery.pending()? { |
| 54 | sync::status(&recovery.connection, edit.id)?; | 65 | recovery.status(edit.id)?; |
| 55 | } | 66 | } |
| 56 | receipts(&recovery.connection)?; | 67 | receipts(&recovery.connection)?; |
| 57 | Ok(recovery) | 68 | Ok(recovery) |
| ... | @@ -63,31 +74,23 @@ impl Recovery { | ... | @@ -63,31 +74,23 @@ impl Recovery { |
| 63 | 74 | ||
| 64 | /// The image the archived queue leaves, as `Replica::snapshot` gives it. | 75 | /// The image the archived queue leaves, as `Replica::snapshot` gives it. |
| 65 | pub fn snapshot(&self) -> Result<Vec<u8>> { | 76 | pub fn snapshot(&self) -> Result<Vec<u8>> { |
| 66 | working::image(&self.connection) | 77 | working::image(&self.connection, self.key.as_ref()) |
| 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 | ) | ||
| 75 | } | 78 | } |
| 76 | 79 | ||
| 77 | /// The last observed remote image. | 80 | /// The last observed remote image. |
| 78 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | 81 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 79 | match base::read(&self.connection, base::Image::Remote)? { | 82 | match base::read(&self.connection, base::Image::Remote)? { |
| 80 | Some(image) => Ok(image), | 83 | Some(image) => Ok(image), |
| 81 | None => self.base(), | 84 | None => base::base(&self.connection), |
| 82 | } | 85 | } |
| 83 | } | 86 | } |
| 84 | 87 | ||
| 85 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | 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 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | 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 | pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> { | 96 | pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> { |
crates/notebook/src/session.rs+334-118| ... | @@ -10,9 +10,10 @@ use onestore::{ | ... | @@ -10,9 +10,10 @@ use onestore::{ |
| 10 | document::Document, | 10 | document::Document, |
| 11 | op::{Edit, Op, SectionOp}, | 11 | op::{Edit, Op, SectionOp}, |
| 12 | page::Page, | 12 | page::Page, |
| 13 | protected, | ||
| 13 | }; | 14 | }; |
| 14 | use std::{ | 15 | use std::{ |
| 15 | collections::BTreeMap, | 16 | collections::{BTreeMap, BTreeSet}, |
| 16 | io, | 17 | io, |
| 17 | io::Write, | 18 | io::Write, |
| 18 | path::{Path, PathBuf}, | 19 | path::{Path, PathBuf}, |
| ... | @@ -99,14 +100,9 @@ pub trait Storage: Send + Sync { | ... | @@ -99,14 +100,9 @@ pub trait Storage: Send + Sync { |
| 99 | fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; | 100 | fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; |
| 100 | /// Publishes a transaction made on the file's current image. | 101 | /// Publishes a transaction made on the file's current image. |
| 101 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>; | 102 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>; |
| 102 | } | 103 | /// Puts the file `with` in the place of the section or TOC at `path` under the coordination |
| 103 | 104 | /// its writers take, provided `path` still has `base`'s stamp. | |
| 104 | /// A password-protected section as `Notebook::unlock` read it. | 105 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>; |
| 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>, | ||
| 110 | } | 106 | } |
| 111 | 107 | ||
| 112 | /// A mounted notebook directory. | 108 | /// A mounted notebook directory. |
| ... | @@ -235,6 +231,14 @@ impl Storage for Directory { | ... | @@ -235,6 +231,14 @@ impl Storage for Directory { |
| 235 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { | 231 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { |
| 236 | Ok(transaction.commit_file(self.path(path))?) | 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 | /// A notebook directory on an SMB share, reached through the native-compatible client. | 244 | /// A notebook directory on an SMB share, reached through the native-compatible client. |
| ... | @@ -275,7 +279,7 @@ impl Storage for Share { | ... | @@ -275,7 +279,7 @@ impl Storage for Share { |
| 275 | fn exists(&self, path: &str) -> bool { | 279 | fn exists(&self, path: &str) -> bool { |
| 276 | let (folder, name) = split(path); | 280 | let (folder, name) = split(path); |
| 277 | self.client | 281 | self.client |
| 278 | .read_dir(&self.path(folder), 100_000) | 282 | .read_dir(&self.path(folder), LIMITS.entries) |
| 279 | .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name)) | 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,9 +326,15 @@ impl Storage for Share { |
| 322 | .client | 326 | .client |
| 323 | .commit_transaction(&self.path(path), transaction)?) | 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 | entries: 100_000, | 338 | entries: 100_000, |
| 329 | bytes_per_file: 256 * 1024 * 1024, | 339 | bytes_per_file: 256 * 1024 * 1024, |
| 330 | depth: 64, | 340 | depth: 64, |
| ... | @@ -415,20 +425,16 @@ impl Notebook { | ... | @@ -415,20 +425,16 @@ impl Notebook { |
| 415 | let catalog = storage.discover(&mut read, LIMITS)?; | 425 | let catalog = storage.discover(&mut read, LIMITS)?; |
| 416 | let location = storage.location(); | 426 | let location = storage.location(); |
| 417 | let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new(); | 427 | let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new(); |
| 418 | let mut folders = vec![&catalog]; | 428 | for section in catalog.sections() { |
| 419 | while let Some(folder) = folders.pop() { | 429 | let identity = match (&root, &section.state) { |
| 420 | for section in &folder.sections { | 430 | (Some(_), discover::SectionState::Readable { document, .. }) => *document, |
| 421 | let identity = match (&root, &section.state) { | 431 | (None, discover::SectionState::Readable { .. }) => section.file_id, |
| 422 | (Some(_), discover::SectionState::Readable { document, .. }) => *document, | 432 | _ => continue, |
| 423 | (None, discover::SectionState::Readable { .. }) => section.file_id, | 433 | }; |
| 424 | _ => continue, | 434 | claims |
| 425 | }; | 435 | .entry(replica_location(&location, section)) |
| 426 | claims | 436 | .or_default() |
| 427 | .entry(replica_location(&location, section)) | 437 | .push((identity, &section.path)); |
| 428 | .or_default() | ||
| 429 | .push((identity, &section.path)); | ||
| 430 | } | ||
| 431 | folders.extend(&folder.groups); | ||
| 432 | } | 438 | } |
| 433 | for (at, sections) in &claims { | 439 | for (at, sections) in &claims { |
| 434 | let identities: Vec<[u8; 16]> = | 440 | let identities: Vec<[u8; 16]> = |
| ... | @@ -447,6 +453,7 @@ impl Notebook { | ... | @@ -447,6 +453,7 @@ impl Notebook { |
| 447 | listing, | 453 | listing, |
| 448 | }; | 454 | }; |
| 449 | notebook.keep_listing(); | 455 | notebook.keep_listing(); |
| 456 | notebook.forget_superseded(); | ||
| 450 | Ok(notebook) | 457 | Ok(notebook) |
| 451 | } | 458 | } |
| 452 | 459 | ||
| ... | @@ -545,30 +552,23 @@ impl Notebook { | ... | @@ -545,30 +552,23 @@ impl Notebook { |
| 545 | } | 552 | } |
| 546 | } | 553 | } |
| 547 | self.catalog = catalog; | 554 | self.catalog = catalog; |
| 555 | self.forget_superseded(); | ||
| 548 | Ok(changes) | 556 | Ok(changes) |
| 549 | } | 557 | } |
| 550 | 558 | ||
| 551 | fn folder(&self, path: &str) -> Result<&discover::Folder> { | 559 | fn folder(&self, path: &str) -> Result<&discover::Folder> { |
| 552 | let mut folders = vec![&self.catalog]; | 560 | self.catalog |
| 553 | while let Some(folder) = folders.pop() { | 561 | .folders() |
| 554 | if folder.path == path { | 562 | .find(|folder| folder.path == path) |
| 555 | return Ok(folder); | 563 | .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) |
| 556 | } | ||
| 557 | folders.extend(&folder.groups); | ||
| 558 | } | ||
| 559 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | ||
| 560 | } | 564 | } |
| 561 | 565 | ||
| 562 | /// A catalog section's path, refusing paths the catalog does not list. | 566 | /// A catalog section's path, refusing paths the catalog does not list. |
| 563 | fn section_path(&self, path: &str) -> Result<&discover::Section> { | 567 | fn section_path(&self, path: &str) -> Result<&discover::Section> { |
| 564 | let mut folders = vec![&self.catalog]; | 568 | self.catalog |
| 565 | while let Some(folder) = folders.pop() { | 569 | .sections() |
| 566 | if let Some(section) = folder.sections.iter().find(|section| section.path == path) { | 570 | .find(|section| section.path == path) |
| 567 | return Ok(section); | 571 | .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) |
| 568 | } | ||
| 569 | folders.extend(&folder.groups); | ||
| 570 | } | ||
| 571 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | ||
| 572 | } | 572 | } |
| 573 | 573 | ||
| 574 | /// A folder's TOC path and file identity, creating the TOC when the folder has none | 574 | /// A folder's TOC path and file identity, creating the TOC when the folder has none |
| ... | @@ -1051,6 +1051,56 @@ impl Notebook { | ... | @@ -1051,6 +1051,56 @@ impl Notebook { |
| 1051 | Ok(purged) | 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 | /// Moves a section or section group into the folder at catalog path `folder`, last, as | 1104 | /// Moves a section or section group into the folder at catalog path `folder`, last, as |
| 1055 | /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's | 1105 | /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's |
| 1056 | /// entry follows it. Returns its new catalog path. | 1106 | /// entry follows it. Returns its new catalog path. |
| ... | @@ -1133,14 +1183,11 @@ impl Notebook { | ... | @@ -1133,14 +1183,11 @@ impl Notebook { |
| 1133 | let Some(link) = onestore::page::link::parse_internal_link(url) else { | 1183 | let Some(link) = onestore::page::link::parse_internal_link(url) else { |
| 1134 | return Ok(None); | 1184 | return Ok(None); |
| 1135 | }; | 1185 | }; |
| 1136 | let mut sections = Vec::new(); | 1186 | let mut sections: Vec<_> = self |
| 1137 | let mut folders = vec![&self.catalog]; | 1187 | .catalog |
| 1138 | while let Some(folder) = folders.pop() { | 1188 | .sections() |
| 1139 | sections.extend(folder.sections.iter().filter(|section| { | 1189 | .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) |
| 1140 | matches!(section.state, discover::SectionState::Readable { .. }) | 1190 | .collect(); |
| 1141 | })); | ||
| 1142 | folders.extend(&folder.groups); | ||
| 1143 | } | ||
| 1144 | sections.sort_by_key(|section| section.file_id != link.section); | 1191 | sections.sort_by_key(|section| section.file_id != link.section); |
| 1145 | let Some(page) = link.page else { | 1192 | let Some(page) = link.page else { |
| 1146 | return Ok(sections | 1193 | return Ok(sections |
| ... | @@ -1168,56 +1215,172 @@ impl Notebook { | ... | @@ -1168,56 +1215,172 @@ impl Notebook { |
| 1168 | self.storage.read(&self.section_path(path)?.path) | 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 | 1218 | /// The key of the password-protected section at catalog `path`, opened with `password`. |
| 1172 | /// buffers go when the pages have been built. | 1219 | /// Edits this device queued before the section was protected elsewhere, held since, are |
| 1173 | #[cfg(feature = "protected")] | 1220 | /// queued again under the key: each page they changed comes back as a copy, as a page |
| 1174 | pub fn unlock(&self, path: &str, password: &str) -> Result<Unlocked> { | 1221 | /// another client removed does, the section's pages having taken new identities. |
| 1175 | let path = self.section_path(path)?.path.clone(); | 1222 | pub fn unlock(&self, path: &str, password: &str) -> Result<protected::Key> { |
| 1176 | let snapshot = self.storage.read(&path)?; | 1223 | let section = self.section_path(path)?; |
| 1177 | let pages = { | 1224 | let image = self.storage.read(&section.path)?; |
| 1178 | let store = Store::parse(&snapshot)?; | 1225 | let key = protected::Key::open(&image, password)?; |
| 1179 | let index = RevisionIndex::parse(&store)?; | 1226 | let held: Vec<PathBuf> = self |
| 1180 | let unlocked = onestore::protected::UnlockedSection::open( | 1227 | .superseded(&[section]) |
| 1181 | &index, | 1228 | .into_iter() |
| 1182 | password, | 1229 | .filter(|(.., queued)| *queued > 0) |
| 1183 | onestore::protected::Limits::default(), | 1230 | .map(|(replica, ..)| replica) |
| 1184 | )?; | 1231 | .collect(); |
| 1185 | let document = unlocked.document()?; | 1232 | if !held.is_empty() { |
| 1186 | document | 1233 | let replica = |
| 1187 | .pages()? | 1234 | Replica::open_or_create(self.replica_path(path)?, Some(&key), || Ok(image))?; |
| 1188 | .into_iter() | 1235 | for path in held { |
| 1189 | .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) | 1236 | // A queue sealed under an earlier password waits for that password. |
| 1190 | .collect::<Result<_>>()? | 1237 | let Ok(old) = Replica::open(&path) else { |
| 1191 | }; | 1238 | continue; |
| 1192 | Ok(Unlocked { pages, snapshot }) | 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 | 1250 | /// The replicas of the files the protected `locked` sections superseded when their |
| 1196 | /// to the file: nothing of a protected section is cached or queued. A section written | 1251 | /// passwords were set (`onestore::protected::rekey` keeps the placement a file's header |
| 1197 | /// since `unlocked` was read refuses the edit; unlock again for its pages. | 1252 | /// names), each held so that no one opens it meanwhile, with how many edits it queues. Only |
| 1198 | #[cfg(feature = "protected")] | 1253 | /// replicas no readable section names are read; one in use is left. |
| 1199 | pub fn apply_unlocked( | 1254 | fn superseded( |
| 1200 | &self, | 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 | path: &str, | 1333 | path: &str, |
| 1202 | password: &str, | 1334 | key: Option<&protected::Key>, |
| 1203 | unlocked: &mut Unlocked, | 1335 | password: Option<&str>, |
| 1204 | author: &str, | 1336 | ) -> Result<Option<protected::Key>> { |
| 1205 | edit: &Edit, | 1337 | let section = self.section_path(path)?; |
| 1206 | ) -> Result<()> { | 1338 | let replica = self.replica_path(path)?; |
| 1207 | let path = self.section_path(path)?.path.clone(); | 1339 | // Held until the file is replaced, so that no session queues edits to the old file. |
| 1208 | let transaction = { | 1340 | let held = replica |
| 1209 | let store = Store::parse(&unlocked.snapshot)?; | 1341 | .exists() |
| 1210 | let index = RevisionIndex::parse(&store)?; | 1342 | .then(|| crate::closed(&replica)) |
| 1211 | onestore::protected::UnlockedSection::open( | 1343 | .transpose()?; |
| 1212 | &index, | 1344 | if let Some(held) = &held |
| 1213 | password, | 1345 | && crate::peek(held)?.1 > 0 |
| 1214 | onestore::protected::Limits::default(), | 1346 | { |
| 1215 | )? | 1347 | return Err(io::Error::new( |
| 1216 | .apply(author, edit)? | 1348 | io::ErrorKind::WouldBlock, |
| 1217 | }; | 1349 | "Edits to the section wait to be published", |
| 1218 | self.storage.commit(&path, &transaction)?; | 1350 | ) |
| 1219 | transaction.apply(&mut unlocked.snapshot)?; | 1351 | .into()); |
| 1220 | Ok(()) | 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 | /// Where the replica of the section at catalog `path` lives, in the notebook's | 1386 | /// Where the replica of the section at catalog `path` lives, in the notebook's |
| ... | @@ -1250,22 +1413,16 @@ impl Notebook { | ... | @@ -1250,22 +1413,16 @@ impl Notebook { |
| 1250 | /// discovery read so that each is read once. | 1413 | /// discovery read so that each is read once. |
| 1251 | pub fn replicas(&mut self) -> Vec<Known> { | 1414 | pub fn replicas(&mut self) -> Vec<Known> { |
| 1252 | let mut images = self.read.take(); | 1415 | let mut images = self.read.take(); |
| 1253 | let mut sections = Vec::new(); | 1416 | self.catalog |
| 1254 | let mut folders = vec![&self.catalog]; | 1417 | .sections() |
| 1255 | while let Some(folder) = folders.pop() { | 1418 | .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) |
| 1256 | for section in &folder.sections { | 1419 | .map(|section| Known { |
| 1257 | if matches!(section.state, discover::SectionState::Readable { .. }) { | 1420 | path: section.path.clone(), |
| 1258 | sections.push(Known { | 1421 | replica: self.replica_path(&section.path).ok(), |
| 1259 | path: section.path.clone(), | 1422 | found: self.read.found(&section.path), |
| 1260 | replica: self.replica_path(&section.path).ok(), | 1423 | image: images.remove(&section.path), |
| 1261 | found: self.read.found(&section.path), | 1424 | }) |
| 1262 | image: images.remove(&section.path), | 1425 | .collect() |
| 1263 | }); | ||
| 1264 | } | ||
| 1265 | } | ||
| 1266 | folders.extend(folder.groups.iter().rev()); | ||
| 1267 | } | ||
| 1268 | sections | ||
| 1269 | } | 1426 | } |
| 1270 | 1427 | ||
| 1271 | /// Keeps the sections of a mounted notebook in sync while they are not open | 1428 | /// Keeps the sections of a mounted notebook in sync while they are not open |
| ... | @@ -1326,6 +1483,37 @@ impl Notebook { | ... | @@ -1326,6 +1483,37 @@ impl Notebook { |
| 1326 | path: &str, | 1483 | path: &str, |
| 1327 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, | 1484 | connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1328 | notify: impl Fn() + Send + 'static, | 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 | ) -> Result<Section> { | 1517 | ) -> Result<Section> { |
| 1330 | let section = self.section_path(path)?; | 1518 | let section = self.section_path(path)?; |
| 1331 | let (path, replicas) = (section.path.clone(), self.replica_folder(section)); | 1519 | let (path, replicas) = (section.path.clone(), self.replica_folder(section)); |
| ... | @@ -1355,7 +1543,7 @@ impl Notebook { | ... | @@ -1355,7 +1543,7 @@ impl Notebook { |
| 1355 | std::fs::create_dir_all(&replicas)?; | 1543 | std::fs::create_dir_all(&replicas)?; |
| 1356 | Ok(replica_file(&replicas, identity)) | 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,7 +1599,18 @@ pub struct StoredPage { |
| 1411 | pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> { | 1599 | pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> { |
| 1412 | let store = Store::parse(image)?; | 1600 | let store = Store::parse(image)?; |
| 1413 | let index = RevisionIndex::parse(&store)?; | 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 | Ok(document | 1614 | Ok(document |
| 1416 | .pages()? | 1615 | .pages()? |
| 1417 | .into_iter() | 1616 | .into_iter() |
| ... | @@ -1461,7 +1660,7 @@ fn component(name: &str) -> bool { | ... | @@ -1461,7 +1660,7 @@ fn component(name: &str) -> bool { |
| 1461 | } | 1660 | } |
| 1462 | 1661 | ||
| 1463 | /// Whether the TOC `image` holds has an entry for the file identity `file`. | 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 | let store = Store::parse(image)?; | 1664 | let store = Store::parse(image)?; |
| 1466 | let index = RevisionIndex::parse(&store)?; | 1665 | let index = RevisionIndex::parse(&store)?; |
| 1467 | let document = Document::parse(&index)?; | 1666 | let document = Document::parse(&index)?; |
| ... | @@ -1508,6 +1707,19 @@ fn replica_location(notebook: &str, section: &discover::Section) -> String { | ... | @@ -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 | /// The replica in `cache` of the section `identity` names. | 1723 | /// The replica in `cache` of the section `identity` names. |
| 1512 | pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { | 1724 | pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { |
| 1513 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); | 1725 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); |
| ... | @@ -1635,13 +1847,14 @@ impl Section { | ... | @@ -1635,13 +1847,14 @@ impl Section { |
| 1635 | })?; | 1847 | })?; |
| 1636 | Ok(replica_file(&folder, identity)) | 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 | 1853 | /// Opens the canonical section `file`, a protected one under `key`, through the replica |
| 1642 | /// identity and image. | 1854 | /// `replica` names for its document identity and image. |
| 1643 | fn open_in<R: Remote + 'static>( | 1855 | fn open_in<R: Remote + 'static>( |
| 1644 | file: PathBuf, | 1856 | file: PathBuf, |
| 1857 | key: Option<&protected::Key>, | ||
| 1645 | replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>, | 1858 | replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>, |
| 1646 | mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, | 1859 | mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, |
| 1647 | notify: impl Fn() + Send + 'static, | 1860 | notify: impl Fn() + Send + 'static, |
| ... | @@ -1650,7 +1863,7 @@ impl Section { | ... | @@ -1650,7 +1863,7 @@ impl Section { |
| 1650 | let store = Store::parse(&source)?; | 1863 | let store = Store::parse(&source)?; |
| 1651 | let identity = RevisionIndex::parse(&store)?.root; | 1864 | let identity = RevisionIndex::parse(&store)?.root; |
| 1652 | let cache = replica(&identity.guid, &source)?; | 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 | let remote = file.clone(); | 1867 | let remote = file.clone(); |
| 1655 | Self::start(file, replica, move || connect(&remote), notify) | 1868 | Self::start(file, replica, move || connect(&remote), notify) |
| 1656 | } | 1869 | } |
| ... | @@ -2015,3 +2228,6 @@ impl Drop for Section { | ... | @@ -2015,3 +2228,6 @@ impl Drop for Section { |
| 2015 | drop(self.worker.take()); | 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,6 +165,10 @@ impl Storage for Folder { |
| 165 | fn commit(&self, _: &str, _: &Transaction) -> Result<()> { | 165 | fn commit(&self, _: &str, _: &Transaction) -> Result<()> { |
| 166 | unimplemented!() | 166 | unimplemented!() |
| 167 | } | 167 | } |
| 168 | |||
| 169 | fn supersede(&self, _: &str, _: &onestore::Stamp, _: &str) -> Result<()> { | ||
| 170 | unimplemented!() | ||
| 171 | } | ||
| 168 | } | 172 | } |
| 169 | 173 | ||
| 170 | #[test] | 174 | #[test] |
crates/notebook/src/smb/mod.rs+29-2| ... | @@ -331,7 +331,7 @@ impl Client { | ... | @@ -331,7 +331,7 @@ impl Client { |
| 331 | 331 | ||
| 332 | /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 | 332 | /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 |
| 333 | /// skips a hidden folder. | 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 | // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES. | 335 | // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES. |
| 336 | let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?; | 336 | let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?; |
| 337 | let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?; | 337 | let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?; |
| ... | @@ -389,7 +389,7 @@ impl Client { | ... | @@ -389,7 +389,7 @@ impl Client { |
| 389 | 389 | ||
| 390 | /// Reads a bounded external payload while denying concurrent writes and deletion. | 390 | /// Reads a bounded external payload while denying concurrent writes and deletion. |
| 391 | /// Empty files succeed; limits, sharing contention and failed close return no payload. | 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 | let mut file = self.open_shared(path, false, 1)?; | 393 | let mut file = self.open_shared(path, false, 1)?; |
| 394 | let mut bytes = Vec::new(); | 394 | let mut bytes = Vec::new(); |
| 395 | let mut block = [0; 65536]; | 395 | let mut block = [0; 65536]; |
| ... | @@ -493,6 +493,33 @@ impl Client { | ... | @@ -493,6 +493,33 @@ impl Client { |
| 493 | self.commit(path, &checked(base), |file| onestore::confirm(file, base)) | 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 | /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks. | 523 | /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks. |
| 497 | fn commit( | 524 | fn commit( |
| 498 | &self, | 525 | &self, |
crates/notebook/src/smb/tests.rs+47-4| ... | @@ -463,9 +463,16 @@ fn live_watch() { | ... | @@ -463,9 +463,16 @@ fn live_watch() { |
| 463 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | 463 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] |
| 464 | fn live_storage_inspection() { | 464 | fn live_storage_inspection() { |
| 465 | let reader = client(); | 465 | let reader = client(); |
| 466 | for (index, fixture) in [ | 466 | // Stably unreadable, never contention: a protected section, and a file too damaged to say. |
| 467 | "native-encrypted/encrypted-01/notebook/synthetic.one", | 467 | for (index, (fixture, unreadable)) in [ |
| 468 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | 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 | .into_iter() | 477 | .into_iter() |
| 471 | .enumerate() | 478 | .enumerate() |
| ... | @@ -482,7 +489,7 @@ fn live_storage_inspection() { | ... | @@ -482,7 +489,7 @@ fn live_storage_inspection() { |
| 482 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); | 489 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); |
| 483 | assert_eq!( | 490 | assert_eq!( |
| 484 | reader.read(&path, source.len()).unwrap_err().kind(), | 491 | reader.read(&path, source.len()).unwrap_err().kind(), |
| 485 | io::ErrorKind::WouldBlock | 492 | unreadable |
| 486 | ); | 493 | ); |
| 487 | let maintenance = client(); | 494 | let maintenance = client(); |
| 488 | let guard = maintenance.open(&path, false).unwrap(); | 495 | let guard = maintenance.open(&path, false).unwrap(); |
| ... | @@ -777,6 +784,42 @@ fn live_structure() { | ... | @@ -777,6 +784,42 @@ fn live_structure() { |
| 777 | assert_eq!(header.ancestor, toc_id); | 784 | assert_eq!(header.ancestor, toc_id); |
| 778 | assert_eq!(header.name_crc, 0x6108912a); | 785 | assert_eq!(header.name_crc, 0x6108912a); |
| 779 | assert!(fresh.section("Renamed.one", || {}).is_err()); | 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 | for path in [ | 823 | for path in [ |
| 781 | "Renamed.one", | 824 | "Renamed.one", |
| 782 | "Kept/Inner.one", | 825 | "Kept/Inner.one", |
crates/notebook/src/sync.rs+32-28| ... | @@ -84,8 +84,7 @@ struct Blocked { | ... | @@ -84,8 +84,7 @@ struct Blocked { |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | fn state(connection: &Connection) -> Result<State> { | 86 | fn state(connection: &Connection) -> Result<State> { |
| 87 | let base = base::stamp(connection, base::Image::Base)? | 87 | let base = base::base_stamp(connection)?; |
| 88 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | ||
| 89 | let blocked = connection | 88 | let blocked = connection |
| 90 | .query_row( | 89 | .query_row( |
| 91 | "SELECT id, revisions FROM batches WHERE attempted=1 ORDER BY id LIMIT 1", | 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,20 +157,17 @@ fn newest(connection: &Connection, batch: i64) -> Result<u64> { |
| 158 | impl Replica { | 157 | impl Replica { |
| 159 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. | 158 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. |
| 160 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | 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 | /// The last observed remote image. Observation alone does not acknowledge any pending | 163 | /// The last observed remote image. Observation alone does not acknowledge any pending |
| 165 | /// edit's remote durability. | 164 | /// edit's remote durability. |
| 166 | pub(crate) fn remote_snapshot(&self) -> Result<Vec<u8>> { | 165 | pub(crate) fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 167 | let connection = self.lock()?; | 166 | let connection = self.lock()?; |
| 168 | let image = match base::read(&connection, base::Image::Remote)? { | 167 | match base::read(&connection, base::Image::Remote)? { |
| 169 | Some(image) => Some(image), | 168 | Some(image) => Ok(image), |
| 170 | None => base::read(&connection, base::Image::Base)?, | 169 | None => base::base(&connection), |
| 171 | }; | 170 | } |
| 172 | Ok(image.ok_or_else(|| { | ||
| 173 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | ||
| 174 | })?) | ||
| 175 | } | 171 | } |
| 176 | 172 | ||
| 177 | /// Publishes the oldest unpublished batch, rebasing the queue first when the remote | 173 | /// Publishes the oldest unpublished batch, rebasing the queue first when the remote |
| ... | @@ -208,7 +204,8 @@ impl Replica { | ... | @@ -208,7 +204,8 @@ impl Replica { |
| 208 | return Ok(Synced { | 204 | return Ok(Synced { |
| 209 | edit: Some(( | 205 | edit: Some(( |
| 210 | id, | 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 | changed: Vec::new(), | 210 | changed: Vec::new(), |
| 214 | }); | 211 | }); |
| ... | @@ -217,9 +214,15 @@ impl Replica { | ... | @@ -217,9 +214,15 @@ impl Replica { |
| 217 | observed if observed == state.base => None, | 214 | observed if observed == state.base => None, |
| 218 | _ => { | 215 | _ => { |
| 219 | let image = remote.read().map_err(Error::RemoteIo)?; | 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 | // A read image is compared whole: a stamp stands for it only when read alone. | 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)?; | 225 | (base::base(&*self.lock()?)? != image).then_some(image) |
| 222 | (base.as_deref() != Some(&image[..])).then_some(image) | ||
| 223 | } | 226 | } |
| 224 | }; | 227 | }; |
| 225 | let mut changed = Vec::new(); | 228 | let mut changed = Vec::new(); |
| ... | @@ -265,7 +268,8 @@ impl Replica { | ... | @@ -265,7 +268,8 @@ impl Replica { |
| 265 | return Ok(Synced { | 268 | return Ok(Synced { |
| 266 | edit: Some(( | 269 | edit: Some(( |
| 267 | id, | 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 | changed, | 274 | changed, |
| 271 | }); | 275 | }); |
| ... | @@ -301,7 +305,8 @@ impl Replica { | ... | @@ -301,7 +305,8 @@ impl Replica { |
| 301 | return Ok(Synced { | 305 | return Ok(Synced { |
| 302 | edit: Some(( | 306 | edit: Some(( |
| 303 | id, | 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 | changed, | 311 | changed, |
| 307 | }); | 312 | }); |
| ... | @@ -329,9 +334,7 @@ impl Replica { | ... | @@ -329,9 +334,7 @@ impl Replica { |
| 329 | let id = newest(&*self.lock()?, batch)?; | 334 | let id = newest(&*self.lock()?, batch)?; |
| 330 | let Some(transaction) = transaction else { | 335 | let Some(transaction) = transaction else { |
| 331 | // Edits that changed nothing are published once the remote's image is durable. | 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(|| { | 337 | let base = base::base_stamp(&*self.lock()?)?; |
| 333 | io::Error::new(io::ErrorKind::InvalidData, "The cache holds no base image") | ||
| 334 | })?; | ||
| 335 | if let Err(error) = remote.confirm(&base) { | 338 | if let Err(error) = remote.confirm(&base) { |
| 336 | if error.state == CommitState::Committed { | 339 | if error.state == CommitState::Committed { |
| 337 | self.acknowledge(batch, None, None)?; | 340 | self.acknowledge(batch, None, None)?; |
| ... | @@ -373,15 +376,13 @@ impl Replica { | ... | @@ -373,15 +376,13 @@ impl Replica { |
| 373 | sealed: Option<&Transaction>, | 376 | sealed: Option<&Transaction>, |
| 374 | revisions: &BTreeMap<ExGuid, ExGuid>, | 377 | revisions: &BTreeMap<ExGuid, ExGuid>, |
| 375 | ) -> Result<bool> { | 378 | ) -> Result<bool> { |
| 376 | let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { | 379 | let base = base::base(&*self.lock()?)?; |
| 377 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | ||
| 378 | })?; | ||
| 379 | let (local, remote) = (onestore::Arena::default(), onestore::Arena::default()); | 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 | if let Some(sealed) = sealed { | 382 | if let Some(sealed) = sealed { |
| 382 | local.replay(sealed)?; | 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 | // A page's versions change without its page changing. | 386 | // A page's versions change without its page changing. |
| 386 | let versions = |section: &mut onestore::Section<'_>| { | 387 | let versions = |section: &mut onestore::Section<'_>| { |
| 387 | section.versions().map(|versions| { | 388 | section.versions().map(|versions| { |
| ... | @@ -403,7 +404,7 @@ impl Replica { | ... | @@ -403,7 +404,7 @@ impl Replica { |
| 403 | } | 404 | } |
| 404 | 405 | ||
| 405 | fn receipt(&self, id: u64) -> Result<ExGuid> { | 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 | Some(EditStatus::Published { revision }) => Ok(revision), | 408 | Some(EditStatus::Published { revision }) => Ok(revision), |
| 408 | _ => Err(io::Error::other("A published edit has no receipt").into()), | 409 | _ => Err(io::Error::other("A published edit has no receipt").into()), |
| 409 | } | 410 | } |
| ... | @@ -431,7 +432,7 @@ impl Replica { | ... | @@ -431,7 +432,7 @@ impl Replica { |
| 431 | |row| row.get::<_, String>(0), | 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 | database.execute( | 436 | database.execute( |
| 436 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", | 437 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", |
| 437 | params![ | 438 | params![ |
| ... | @@ -527,7 +528,11 @@ impl Replica { | ... | @@ -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 | let id = signed(id)?; | 536 | let id = signed(id)?; |
| 532 | if let Some(revision) = connection | 537 | if let Some(revision) = connection |
| 533 | .query_row( | 538 | .query_row( |
| ... | @@ -563,8 +568,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat | ... | @@ -563,8 +568,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 563 | None => None, | 568 | None => None, |
| 564 | Some((true, revisions, edit)) => { | 569 | Some((true, revisions, edit)) => { |
| 565 | let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; | 570 | let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; |
| 566 | let edit: onestore::op::Edit = serde_json::from_str(&edit) | 571 | let edit = queue::parse(key, &edit)?; |
| 567 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | ||
| 568 | let revision = revision_of(&edit, &revisions)?; | 572 | let revision = revision_of(&edit, &revisions)?; |
| 569 | Some(EditStatus::AwaitingConfirmation { revision }) | 573 | Some(EditStatus::AwaitingConfirmation { revision }) |
| 570 | } | 574 | } |
crates/notebook/src/worker.rs+1| ... | @@ -166,6 +166,7 @@ impl Replica { | ... | @@ -166,6 +166,7 @@ impl Replica { |
| 166 | )); | 166 | )); |
| 167 | } | 167 | } |
| 168 | let mut owner = self | 168 | let mut owner = self |
| 169 | .section | ||
| 169 | .worker | 170 | .worker |
| 170 | .lock() | 171 | .lock() |
| 171 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; | 172 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; |
crates/notebook/src/working.rs+63-56| ... | @@ -8,6 +8,7 @@ use onestore::{ | ... | @@ -8,6 +8,7 @@ use onestore::{ |
| 8 | Arena, ExGuid, Section, Transaction, | 8 | Arena, ExGuid, Section, Transaction, |
| 9 | op::{Edit, OpError}, | 9 | op::{Edit, OpError}, |
| 10 | page::Page, | 10 | page::Page, |
| 11 | protected::Key, | ||
| 11 | }; | 12 | }; |
| 12 | use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; | 13 | use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; |
| 13 | use std::{ | 14 | use std::{ |
| ... | @@ -96,18 +97,19 @@ impl Request { | ... | @@ -96,18 +97,19 @@ impl Request { |
| 96 | } | 97 | } |
| 97 | } | 98 | } |
| 98 | 99 | ||
| 99 | type Worker = Arc<Mutex<Weak<Signal>>>; | ||
| 100 | |||
| 101 | /// The section thread as the replica sees it. Rereading the section after the queue was | 100 | /// The section thread as the replica sees it. Rereading the section after the queue was |
| 102 | /// replaced (a rebase, a released attempt) happens on a new thread while the current one | 101 | /// replaced (a rebase, a released attempt) happens on a new thread while the current one |
| 103 | /// keeps answering page reads from the section as it was; the new thread then takes the | 102 | /// keeps answering page reads from the section as it was; the new thread then takes the |
| 104 | /// requests over, so reads never wait for a rebuild. | 103 | /// requests over, so reads never wait for a rebuild. |
| 105 | pub(crate) struct Thread { | 104 | pub(crate) struct Thread { |
| 106 | connection: Arc<Mutex<Connection>>, | 105 | pub(crate) connection: Mutex<Connection>, |
| 107 | worker: Worker, | 106 | /// The sync worker, which a durable burst of edits wakes. |
| 107 | pub(crate) worker: Mutex<Weak<Signal>>, | ||
| 108 | /// Taken when the replica drops, which ends every section thread. | 108 | /// Taken when the replica drops, which ends every section thread. |
| 109 | sender: Mutex<Option<mpsc::Sender<Request>>>, | 109 | sender: Mutex<Option<mpsc::Sender<Request>>>, |
| 110 | threads: Mutex<Vec<thread::JoinHandle<()>>>, | 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 | impl Thread { | 115 | impl Thread { |
| ... | @@ -148,16 +150,14 @@ impl Thread { | ... | @@ -148,16 +150,14 @@ impl Thread { |
| 148 | } | 150 | } |
| 149 | 151 | ||
| 150 | /// Starts the section thread once the queue opens. | 152 | /// Starts the section thread once the queue opens. |
| 151 | pub(crate) fn spawn( | 153 | pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thread>, ExGuid)> { |
| 152 | connection: Arc<Mutex<Connection>>, | ||
| 153 | worker: Worker, | ||
| 154 | ) -> Result<(Arc<Thread>, ExGuid)> { | ||
| 155 | let (sender, requests) = mpsc::channel(); | 154 | let (sender, requests) = mpsc::channel(); |
| 156 | let shared = Arc::new(Thread { | 155 | let shared = Arc::new(Thread { |
| 157 | connection, | 156 | connection: Mutex::new(connection), |
| 158 | worker, | 157 | worker: Mutex::new(Weak::new()), |
| 159 | sender: Mutex::new(Some(sender)), | 158 | sender: Mutex::new(Some(sender)), |
| 160 | threads: Mutex::new(Vec::new()), | 159 | threads: Mutex::new(Vec::new()), |
| 160 | key, | ||
| 161 | }); | 161 | }); |
| 162 | let (ready, opened) = mpsc::sync_channel(1); | 162 | let (ready, opened) = mpsc::sync_channel(1); |
| 163 | shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; | 163 | shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; |
| ... | @@ -188,7 +188,7 @@ fn run( | ... | @@ -188,7 +188,7 @@ fn run( |
| 188 | ) { | 188 | ) { |
| 189 | loop { | 189 | loop { |
| 190 | let arena = Arena::default(); | 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 | Ok(working) => working, | 192 | Ok(working) => working, |
| 193 | Err(error) => { | 193 | Err(error) => { |
| 194 | if let Some(ready) = ready.take() { | 194 | if let Some(ready) = ready.take() { |
| ... | @@ -237,17 +237,19 @@ enum Job { | ... | @@ -237,17 +237,19 @@ enum Job { |
| 237 | fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) { | 237 | fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) { |
| 238 | let answer: Box<dyn FnOnce() + Send> = match job { | 238 | let answer: Box<dyn FnOnce() + Send> = match job { |
| 239 | Job::Reopen { reply } => Box::new(move || reply(Ok(()))), | 239 | Job::Reopen { reply } => Box::new(move || reply(Ok(()))), |
| 240 | Job::Rebase { image, reply } => match rebase(&shared.connection, image) { | 240 | Job::Rebase { image, reply } => { |
| 241 | Ok(changed) => Box::new(move || reply(Ok(changed))), | 241 | match rebase(&shared.connection, image, shared.key.as_ref()) { |
| 242 | Err(error) => { | 242 | Ok(changed) => Box::new(move || reply(Ok(changed))), |
| 243 | reply(Err(error)); | 243 | Err(error) => { |
| 244 | let _ = signal.send(Request::Resume); | 244 | reply(Err(error)); |
| 245 | return; | 245 | let _ = signal.send(Request::Resume); |
| 246 | return; | ||
| 247 | } | ||
| 246 | } | 248 | } |
| 247 | }, | 249 | } |
| 248 | }; | 250 | }; |
| 249 | let arena = Arena::default(); | 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 | let (to, from) = mpsc::sync_channel(1); | 253 | let (to, from) = mpsc::sync_channel(1); |
| 252 | if signal.send(Request::Handover(to)).is_err() { | 254 | if signal.send(Request::Handover(to)).is_err() { |
| 253 | return; | 255 | return; |
| ... | @@ -286,8 +288,8 @@ struct Working<'a> { | ... | @@ -286,8 +288,8 @@ struct Working<'a> { |
| 286 | } | 288 | } |
| 287 | 289 | ||
| 288 | impl<'a> Working<'a> { | 290 | impl<'a> Working<'a> { |
| 289 | fn open(arena: &'a Arena, connection: &Mutex<Connection>) -> Result<Self> { | 291 | fn open(arena: &'a Arena, connection: &Mutex<Connection>, key: Option<&Key>) -> Result<Self> { |
| 290 | let (section, open, touched) = replay(arena, &*lock(connection)?)?; | 292 | let (section, open, touched) = replay(arena, &*lock(connection)?, key)?; |
| 291 | Ok(Self { | 293 | Ok(Self { |
| 292 | section, | 294 | section, |
| 293 | open, | 295 | open, |
| ... | @@ -301,7 +303,7 @@ impl<'a> Working<'a> { | ... | @@ -301,7 +303,7 @@ impl<'a> Working<'a> { |
| 301 | requests: &mpsc::Receiver<Request>, | 303 | requests: &mpsc::Receiver<Request>, |
| 302 | backlog: &mut VecDeque<Request>, | 304 | backlog: &mut VecDeque<Request>, |
| 303 | ) -> Next { | 305 | ) -> Next { |
| 304 | let (connection, worker) = (&*shared.connection, &shared.worker); | 306 | let connection = &shared.connection; |
| 305 | // While another thread rebuilds the section, reads answer from this one and every | 307 | // While another thread rebuilds the section, reads answer from this one and every |
| 306 | // other request waits for the rebuilt section. | 308 | // other request waits for the rebuilt section. |
| 307 | let mut held: Option<VecDeque<Request>> = None; | 309 | let mut held: Option<VecDeque<Request>> = None; |
| ... | @@ -356,7 +358,7 @@ impl<'a> Working<'a> { | ... | @@ -356,7 +358,7 @@ impl<'a> Working<'a> { |
| 356 | edit, | 358 | edit, |
| 357 | reply, | 359 | reply, |
| 358 | } => self | 360 | } => self |
| 359 | .accept(author, edit, reply, &mut accepted, connection, worker) | 361 | .accept(author, edit, reply, &mut accepted, shared) |
| 360 | .err() | 362 | .err() |
| 361 | .unwrap_or(false), | 363 | .unwrap_or(false), |
| 362 | Request::Page { space, reply } => { | 364 | Request::Page { space, reply } => { |
| ... | @@ -390,7 +392,7 @@ impl<'a> Working<'a> { | ... | @@ -390,7 +392,7 @@ impl<'a> Working<'a> { |
| 390 | false | 392 | false |
| 391 | } | 393 | } |
| 392 | Request::Flush { reply } => { | 394 | Request::Flush { reply } => { |
| 393 | let written = self.flush(connection, worker, &mut accepted); | 395 | let written = self.flush(shared, &mut accepted); |
| 394 | reply(if written { | 396 | reply(if written { |
| 395 | Ok(()) | 397 | Ok(()) |
| 396 | } else { | 398 | } else { |
| ... | @@ -399,7 +401,7 @@ impl<'a> Working<'a> { | ... | @@ -399,7 +401,7 @@ impl<'a> Working<'a> { |
| 399 | !written | 401 | !written |
| 400 | } | 402 | } |
| 401 | Request::Seal { reply } => { | 403 | Request::Seal { reply } => { |
| 402 | if !self.flush(connection, worker, &mut accepted) { | 404 | if !self.flush(shared, &mut accepted) { |
| 403 | reply(Err( | 405 | reply(Err( |
| 404 | io::Error::other("The queue could not be written").into() | 406 | io::Error::other("The queue could not be written").into() |
| 405 | )); | 407 | )); |
| ... | @@ -412,7 +414,7 @@ impl<'a> Working<'a> { | ... | @@ -412,7 +414,7 @@ impl<'a> Working<'a> { |
| 412 | } | 414 | } |
| 413 | } | 415 | } |
| 414 | Request::Rebase { image, reply } => { | 416 | Request::Rebase { image, reply } => { |
| 415 | if self.flush(connection, worker, &mut accepted) { | 417 | if self.flush(shared, &mut accepted) { |
| 416 | held = self | 418 | held = self |
| 417 | .rebuild(shared, Job::Rebase { image, reply }) | 419 | .rebuild(shared, Job::Rebase { image, reply }) |
| 418 | .then(VecDeque::new); | 420 | .then(VecDeque::new); |
| ... | @@ -425,7 +427,7 @@ impl<'a> Working<'a> { | ... | @@ -425,7 +427,7 @@ impl<'a> Working<'a> { |
| 425 | } | 427 | } |
| 426 | } | 428 | } |
| 427 | Request::Reopen { reply } => { | 429 | Request::Reopen { reply } => { |
| 428 | self.flush(connection, worker, &mut accepted); | 430 | self.flush(shared, &mut accepted); |
| 429 | held = self | 431 | held = self |
| 430 | .rebuild(shared, Job::Reopen { reply }) | 432 | .rebuild(shared, Job::Reopen { reply }) |
| 431 | .then(VecDeque::new); | 433 | .then(VecDeque::new); |
| ... | @@ -438,7 +440,7 @@ impl<'a> Working<'a> { | ... | @@ -438,7 +440,7 @@ impl<'a> Working<'a> { |
| 438 | return Next::Reopen; | 440 | return Next::Reopen; |
| 439 | } | 441 | } |
| 440 | } | 442 | } |
| 441 | if !self.flush(connection, worker, &mut accepted) { | 443 | if !self.flush(shared, &mut accepted) { |
| 442 | return Next::Reopen; | 444 | return Next::Reopen; |
| 443 | } | 445 | } |
| 444 | } | 446 | } |
| ... | @@ -476,8 +478,7 @@ impl<'a> Working<'a> { | ... | @@ -476,8 +478,7 @@ impl<'a> Working<'a> { |
| 476 | edit: Edit, | 478 | edit: Edit, |
| 477 | reply: Reply<u64>, | 479 | reply: Reply<u64>, |
| 478 | accepted: &mut Vec<Accepted>, | 480 | accepted: &mut Vec<Accepted>, |
| 479 | connection: &Mutex<Connection>, | 481 | shared: &Thread, |
| 480 | worker: &Worker, | ||
| 481 | ) -> std::result::Result<(), bool> { | 482 | ) -> std::result::Result<(), bool> { |
| 482 | match self.section.apply(&author, &edit) { | 483 | match self.section.apply(&author, &edit) { |
| 483 | Ok(()) => { | 484 | Ok(()) => { |
| ... | @@ -493,7 +494,7 @@ impl<'a> Working<'a> { | ... | @@ -493,7 +494,7 @@ impl<'a> Working<'a> { |
| 493 | Err(error) => { | 494 | Err(error) => { |
| 494 | let broken = matches!(error, OpError::Failed(_)); | 495 | let broken = matches!(error, OpError::Failed(_)); |
| 495 | if broken { | 496 | if broken { |
| 496 | self.flush(connection, worker, accepted); | 497 | self.flush(shared, accepted); |
| 497 | } | 498 | } |
| 498 | reply(Err(error.into())); | 499 | reply(Err(error.into())); |
| 499 | Err(broken) | 500 | Err(broken) |
| ... | @@ -503,17 +504,12 @@ impl<'a> Working<'a> { | ... | @@ -503,17 +504,12 @@ impl<'a> Working<'a> { |
| 503 | 504 | ||
| 504 | /// Writes the accepted edits in one transaction, then answers their senders; false | 505 | /// Writes the accepted edits in one transaction, then answers their senders; false |
| 505 | /// when the write failed and the section holds edits the queue lacks. | 506 | /// when the write failed and the section holds edits the queue lacks. |
| 506 | fn flush( | 507 | fn flush(&mut self, shared: &Thread, accepted: &mut Vec<Accepted>) -> bool { |
| 507 | &mut self, | ||
| 508 | connection: &Mutex<Connection>, | ||
| 509 | worker: &Worker, | ||
| 510 | accepted: &mut Vec<Accepted>, | ||
| 511 | ) -> bool { | ||
| 512 | if accepted.is_empty() { | 508 | if accepted.is_empty() { |
| 513 | return true; | 509 | return true; |
| 514 | } | 510 | } |
| 515 | let written = (|| -> Result<Vec<u64>> { | 511 | let written = (|| -> Result<Vec<u64>> { |
| 516 | let mut connection = lock(connection)?; | 512 | let mut connection = lock(&shared.connection)?; |
| 517 | let transaction = | 513 | let transaction = |
| 518 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | 514 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 519 | let batch = match self.open { | 515 | let batch = match self.open { |
| ... | @@ -527,6 +523,7 @@ impl<'a> Working<'a> { | ... | @@ -527,6 +523,7 @@ impl<'a> Working<'a> { |
| 527 | for edit in accepted.iter() { | 523 | for edit in accepted.iter() { |
| 528 | ids.push(queue::insert( | 524 | ids.push(queue::insert( |
| 529 | &transaction, | 525 | &transaction, |
| 526 | shared.key.as_ref(), | ||
| 530 | None, | 527 | None, |
| 531 | batch, | 528 | batch, |
| 532 | &edit.author, | 529 | &edit.author, |
| ... | @@ -543,7 +540,7 @@ impl<'a> Working<'a> { | ... | @@ -543,7 +540,7 @@ impl<'a> Working<'a> { |
| 543 | for (edit, id) in accepted.drain(..).zip(ids) { | 540 | for (edit, id) in accepted.drain(..).zip(ids) { |
| 544 | (edit.reply)(Ok(id)); | 541 | (edit.reply)(Ok(id)); |
| 545 | } | 542 | } |
| 546 | crate::edited(worker); | 543 | crate::edited(&shared.worker); |
| 547 | } | 544 | } |
| 548 | Err(error) => { | 545 | Err(error) => { |
| 549 | let message = error.to_string(); | 546 | let message = error.to_string(); |
| ... | @@ -600,7 +597,11 @@ impl<'a> Working<'a> { | ... | @@ -600,7 +597,11 @@ impl<'a> Working<'a> { |
| 600 | /// Replays every queued edit on `image`, which becomes the base; each page whose local | 597 | /// Replays every queued edit on `image`, which becomes the base; each page whose local |
| 601 | /// version the remote could not take gains a conflict page holding it, queued as a new edit. | 598 | /// version the remote could not take gains a conflict page holding it, queued as a new edit. |
| 602 | /// Returns the pages the remote changed. | 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 | let (base_image, image, edits) = { | 605 | let (base_image, image, edits) = { |
| 605 | let connection = lock(connection)?; | 606 | let connection = lock(connection)?; |
| 606 | if connection.query_row( | 607 | if connection.query_row( |
| ... | @@ -614,22 +615,20 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< | ... | @@ -614,22 +615,20 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 614 | ) | 615 | ) |
| 615 | .into()); | 616 | .into()); |
| 616 | } | 617 | } |
| 617 | let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { | 618 | let base_image = base::base(&connection)?; |
| 618 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | ||
| 619 | })?; | ||
| 620 | let image = match image { | 619 | let image = match image { |
| 621 | Some(image) => image, | 620 | Some(image) => image, |
| 622 | None => { | 621 | None => { |
| 623 | base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone()) | 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 | let old_arena = Arena::default(); | 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 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); | 629 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); |
| 631 | let remote_arena = Arena::default(); | 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 | if old.root() != remote.root() { | 632 | if old.root() != remote.root() { |
| 634 | return Err(io::Error::new( | 633 | return Err(io::Error::new( |
| 635 | io::ErrorKind::InvalidInput, | 634 | io::ErrorKind::InvalidInput, |
| ... | @@ -645,10 +644,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< | ... | @@ -645,10 +644,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 645 | let mut converged = BTreeSet::new(); | 644 | let mut converged = BTreeSet::new(); |
| 646 | let (rewritten, added) = loop { | 645 | let (rewritten, added) = loop { |
| 647 | let arena = Arena::default(); | 646 | let arena = Arena::default(); |
| 648 | let mut new = Section::open(&arena, image.clone())?; | 647 | let mut new = open(&arena, image.clone(), key)?; |
| 649 | let merged = merge::rebase(&mut old, &mut new, &edits, &converged)?; | 648 | let merged = merge::rebase(&mut old, &mut new, &edits, &converged)?; |
| 650 | if !merged.conflicts.is_empty() && local.is_none() { | 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 | let pages: BTreeMap<ExGuid, Page> = merged | 652 | let pages: BTreeMap<ExGuid, Page> = merged |
| 654 | .conflicts | 653 | .conflicts |
| ... | @@ -694,10 +693,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< | ... | @@ -694,10 +693,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< |
| 694 | transaction.execute("UPDATE edits SET batch=?1", [batch])?; | 693 | transaction.execute("UPDATE edits SET batch=?1", [batch])?; |
| 695 | transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; | 694 | transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; |
| 696 | for (id, edit) in rewritten { | 695 | for (id, edit) in rewritten { |
| 697 | queue::rewrite(&transaction, id, &edit)?; | 696 | queue::rewrite(&transaction, key, id, &edit)?; |
| 698 | } | 697 | } |
| 699 | for (author, edit) in &added { | 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 | if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 { | 701 | if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 { |
| 703 | transaction.execute("DELETE FROM batches", [])?; | 702 | transaction.execute("DELETE FROM batches", [])?; |
| ... | @@ -743,10 +742,10 @@ fn batches(connection: &Connection) -> Result<Vec<(i64, Option<Option<Transactio | ... | @@ -743,10 +742,10 @@ fn batches(connection: &Connection) -> Result<Vec<(i64, Option<Option<Transactio |
| 743 | fn replay<'a>( | 742 | fn replay<'a>( |
| 744 | arena: &'a Arena, | 743 | arena: &'a Arena, |
| 745 | connection: &Connection, | 744 | connection: &Connection, |
| 745 | key: Option<&Key>, | ||
| 746 | ) -> Result<(Section<'a>, Option<i64>, BTreeSet<ExGuid>)> { | 746 | ) -> Result<(Section<'a>, Option<i64>, BTreeSet<ExGuid>)> { |
| 747 | let image = base::read(connection, base::Image::Base)? | 747 | let image = base::base(connection)?; |
| 748 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | 748 | let mut section = open(arena, image, key)?; |
| 749 | let mut section = Section::open(arena, image)?; | ||
| 750 | let mut open = None; | 749 | let mut open = None; |
| 751 | let mut touched = BTreeSet::new(); | 750 | let mut touched = BTreeSet::new(); |
| 752 | for (batch, sealed) in batches(connection)? { | 751 | for (batch, sealed) in batches(connection)? { |
| ... | @@ -761,7 +760,7 @@ fn replay<'a>( | ... | @@ -761,7 +760,7 @@ fn replay<'a>( |
| 761 | Some(Some(transaction)) => section.replay(&transaction)?, | 760 | Some(Some(transaction)) => section.replay(&transaction)?, |
| 762 | Some(None) => {} | 761 | Some(None) => {} |
| 763 | None => { | 762 | None => { |
| 764 | for queued in queue::load(connection, Some(batch))? { | 763 | for queued in queue::load(connection, key, Some(batch))? { |
| 765 | section | 764 | section |
| 766 | .apply(&queued.author, &queued.edit) | 765 | .apply(&queued.author, &queued.edit) |
| 767 | .map_err(|error| { | 766 | .map_err(|error| { |
| ... | @@ -781,13 +780,21 @@ fn replay<'a>( | ... | @@ -781,13 +780,21 @@ fn replay<'a>( |
| 781 | 780 | ||
| 782 | /// The image the queue leaves, its unsealed edits sealed as one more revision; that | 781 | /// The image the queue leaves, its unsealed edits sealed as one more revision; that |
| 783 | /// revision's identities are fresh on every call. O(section), for tests and recovery. | 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 | let arena = Arena::default(); | 784 | let arena = Arena::default(); |
| 786 | let (mut section, ..) = replay(&arena, connection)?; | 785 | let (mut section, ..) = replay(&arena, connection, key)?; |
| 787 | section.seal()?; | 786 | section.seal()?; |
| 788 | Ok(section.image()) | 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 | /// The sealed batch waiting for publication, if any. | 798 | /// The sealed batch waiting for publication, if any. |
| 792 | pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> { | 799 | pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> { |
| 793 | let row: Option<(i64, String)> = connection | 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,14 +66,14 @@ fn a_cache_created_meanwhile_opens() { |
| 66 | let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap(); | 66 | let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap(); |
| 67 | let directory = tempfile::tempdir().unwrap(); | 67 | let directory = tempfile::tempdir().unwrap(); |
| 68 | let path = directory.path().join("raced.sqlite"); | 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 | drop(Replica::create(&path, &source)?); | 70 | drop(Replica::create(&path, &source)?); |
| 71 | Ok(source.clone()) | 71 | Ok(source.clone()) |
| 72 | }) | 72 | }) |
| 73 | .unwrap(); | 73 | .unwrap(); |
| 74 | assert_eq!(replica.pages().unwrap()[0].1, "Raced"); | 74 | assert_eq!(replica.pages().unwrap()[0].1, "Raced"); |
| 75 | drop(replica); | 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 | assert_eq!(reopened.pages().unwrap()[0].1, "Raced"); | 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,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 | use std::path::Path; | 11 | use std::path::Path; |
| 5 | 12 | ||
| 6 | /// A locked section is discovered as such and reads with its password, page by page. | 13 | #[path = "support/server.rs"] |
| 7 | #[test] | 14 | mod server; |
| 8 | fn a_locked_section_unlocks_for_reading() { | 15 | use server::Server; |
| 9 | let root = Path::new(concat!( | 16 | |
| 10 | env!("CARGO_MANIFEST_DIR"), | 17 | fn fixture(name: &str) -> (std::path::PathBuf, String) { |
| 11 | "/../../corpus/native-protected-boundaries" | 18 | let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(name); |
| 12 | )); | ||
| 13 | let manifest: serde_json::Value = | 19 | let manifest: serde_json::Value = |
| 14 | serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); | 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 | let temporary = tempfile::tempdir().unwrap(); | 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 | let section = &notebook.catalog().sections[0]; | 104 | let section = &notebook.catalog().sections[0]; |
| 19 | assert!(matches!(section.state, SectionState::Locked)); | 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 | assert!(matches!( | 106 | assert!(matches!( |
| 24 | notebook.unlock(&section.path, "wrong"), | 107 | notebook.unlock(&section.path, "wrong"), |
| 25 | Err(notebook::Error::Protected( | 108 | Err(notebook::Error::Protected(Error::PasswordMismatch)) |
| 26 | onestore::protected::Error::PasswordMismatch | 109 | )); |
| 27 | )) | 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 | 127 | /// Queued edits and their payloads are sealed in the replica, which opens again only under |
| 32 | /// the same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for | 128 | /// the section's key, and publish into the file under it. |
| 33 | /// a cold reopen in OneNote. | ||
| 34 | #[test] | 129 | #[test] |
| 35 | fn an_unlocked_page_is_saved_under_the_section_key() { | 130 | fn queued_edits_are_sealed_and_publish_under_the_key() { |
| 36 | use onestore::page::{PageObject, Paragraph, text::new_id}; | 131 | const SECRET: &str = "Queued in confidence"; |
| 37 | let root = Path::new(concat!( | 132 | let (source, key) = protected("Opening line"); |
| 38 | env!("CARGO_MANIFEST_DIR"), | 133 | let (space, text, _) = first_text(&source, &key); |
| 39 | "/../../corpus/native-encrypted" | 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 = | 192 | drop(cache); |
| 42 | serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); | 193 | assert!(!leaks(&path, SECRET)); |
| 43 | let password = manifest["password"].as_str().unwrap(); | 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 | let temporary = tempfile::tempdir().unwrap(); | 270 | let temporary = tempfile::tempdir().unwrap(); |
| 45 | let copy = temporary.path().join("notebook"); | 271 | let folder = temporary.path().join("notebook"); |
| 46 | std::fs::create_dir(&copy).unwrap(); | 272 | let cache = temporary.path().join("cache"); |
| 47 | for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() { | 273 | let creation = onestore::PageCreation::new(None, Some(TEXT), "Fixture").unwrap(); |
| 48 | let entry = entry.unwrap(); | 274 | let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); |
| 49 | std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap(); | 275 | let path = notebook.catalog().sections[0].path.clone(); |
| 50 | } | 276 | let plain = notebook.replica_path(&path).unwrap(); |
| 51 | let notebook = Notebook::open(&copy, temporary.path().join("cache")).unwrap(); | 277 | notebook.section(&path, || {}).unwrap().close().unwrap(); |
| 52 | let section = notebook | 278 | assert!(plain.exists()); |
| 53 | .catalog() | 279 | let identity = notebook.catalog().sections[0].file_id; |
| 54 | .sections | 280 | |
| 55 | .iter() | 281 | let first = notebook |
| 56 | .find(|section| matches!(section.state, SectionState::Locked)) | 282 | .set_password(&path, None, Some("first")) |
| 57 | .unwrap() | 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 | .unwrap(); | 284 | .unwrap(); |
| 71 | let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); | 285 | assert!(!plain.exists()); |
| 72 | let mut added = paragraphs[at].clone(); | 286 | let section = &notebook.catalog().sections[0]; |
| 73 | added.id = new_id().unwrap(); | 287 | assert!(matches!(section.state, SectionState::Locked)); |
| 74 | added.style = None; | 288 | assert_ne!(section.file_id, identity); |
| 75 | added.lists.clear(); | 289 | assert!( |
| 76 | added.tags.clear(); | 290 | notebook |
| 77 | let text = added.text_mut().unwrap(); | 291 | .catalog() |
| 78 | text.id = new_id().unwrap(); | 292 | .toc |
| 79 | let format = text.text.format_at(0).unwrap().clone(); | 293 | .as_ref() |
| 80 | text.text = Paragraph::new( | 294 | .unwrap() |
| 81 | "Written while protected 🦀".to_owned(), | 295 | .unresolved |
| 82 | onestore::document::Format { | 296 | .is_empty() |
| 83 | font: Some("Calibri".into()), | ||
| 84 | font_size: Some(11.0), | ||
| 85 | language: Some(1033), | ||
| 86 | ..Default::default() | ||
| 87 | }, | ||
| 88 | ); | 297 | ); |
| 89 | paragraphs.insert(at + 1, added); | 298 | let image = notebook.read_section(&path).unwrap(); |
| 90 | paragraphs[at] | 299 | let utf16: Vec<u8> = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect(); |
| 91 | .text_mut() | 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 | .unwrap() | 311 | .unwrap() |
| 93 | .text | ||
| 94 | .append(Paragraph::new( | ||
| 95 | " and edited under its key".to_owned(), | ||
| 96 | format, | ||
| 97 | )) | ||
| 98 | .unwrap(); | 312 | .unwrap(); |
| 99 | let edit = onestore::op::Edit { | 313 | assert!(notebook.unlock(&path, "first").is_err()); |
| 100 | at: 134_000_000_000_000_000, | 314 | let session = notebook.section_unlocked(&path, &second, || {}).unwrap(); |
| 101 | ops: onestore::op::lower_page(&before, &page) | 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 | .unwrap() | 321 | .unwrap() |
| 103 | .into_iter() | 322 | .is_none() |
| 104 | .map(|op| onestore::op::Op::Page { space, op }) | 323 | ); |
| 105 | .collect(), | 324 | let section = &notebook.catalog().sections[0]; |
| 106 | }; | 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 | assert!(matches!( | 397 | assert!(matches!( |
| 108 | notebook.apply_unlocked(&section, "wrong", &mut unlocked, "Rust", &edit), | 398 | again.unwrap().catalog().sections[0].state, |
| 109 | Err(notebook::Error::Protected( | 399 | SectionState::Locked |
| 110 | onestore::protected::Error::PasswordMismatch | ||
| 111 | )) | ||
| 112 | )); | 400 | )); |
| 113 | let mut stale = notebook.unlock(&section, password).unwrap(); | 401 | assert!(!leaks(&replica, TITLE)); |
| 114 | notebook | 402 | } |
| 115 | .apply_unlocked(&section, password, &mut unlocked, "Rust", &edit) | 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 | .unwrap(); | 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 | assert!( | 635 | assert!( |
| 119 | notebook | 636 | notebook |
| 120 | .apply_unlocked(&section, password, &mut stale, "Rust", &edit) | 637 | .catalog() |
| 121 | .is_err() | 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); | 644 | let key = notebook |
| 124 | assert!(stored.objects == page.objects); | 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 | if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { | 657 | if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { |
| 126 | let export = Path::new(&export); | 658 | let export = Path::new(&export); |
| 127 | std::fs::create_dir_all(export).unwrap(); | 659 | copy(&folder, &export.join("notebook")); |
| 128 | for entry in std::fs::read_dir(&copy).unwrap() { | 660 | std::fs::write( |
| 129 | let entry = entry.unwrap(); | 661 | export.join("passwords.json"), |
| 130 | std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap(); | 662 | serde_json::to_vec_pretty(&passwords).unwrap(), |
| 131 | } | 663 | ) |
| 664 | .unwrap(); | ||
| 132 | } | 665 | } |
| 133 | } | 666 | } |
crates/notebook/tests/support/page_schedule.rs+6-44| ... | @@ -5,12 +5,9 @@ use crate::{ | ... | @@ -5,12 +5,9 @@ use crate::{ |
| 5 | use notebook::{Remote, Replica}; | 5 | use notebook::{Remote, Replica}; |
| 6 | use onestore::{ | 6 | use onestore::{ |
| 7 | CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction, | 7 | CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction, |
| 8 | document::{Document, Format, Kind, Layout}, | 8 | document::{Document, Kind}, |
| 9 | op::SectionOp, | 9 | op::SectionOp, |
| 10 | page::{ | 10 | page::{Page, PageObject}, |
| 11 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, | ||
| 12 | text::new_id, | ||
| 13 | }, | ||
| 14 | }; | 11 | }; |
| 15 | use std::{io, sync::LazyLock}; | 12 | use std::{io, sync::LazyLock}; |
| 16 | 13 | ||
| ... | @@ -54,50 +51,15 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { | ... | @@ -54,50 +51,15 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { |
| 54 | /// Adds a body outline holding one plain paragraph, returning its text identity. A page the | 51 | /// Adds a body outline holding one plain paragraph, returning its text identity. A page the |
| 55 | /// section just created has no body text for `model_ops::insert_outline` to copy formatting from. | 52 | /// section just created has no body text for `model_ops::insert_outline` to copy formatting from. |
| 56 | pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { | 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. | 54 | let paragraph = ops::paragraph(text); |
| 58 | let format = Format { | 55 | let id = paragraph.text().unwrap().id; |
| 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 | }; | ||
| 95 | let at = page | 56 | let at = page |
| 96 | .objects | 57 | .objects |
| 97 | .iter() | 58 | .iter() |
| 98 | .position(|object| matches!(object, PageObject::Title(_))) | 59 | .position(|object| matches!(object, PageObject::Title(_))) |
| 99 | .unwrap_or(page.objects.len()); | 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 | id | 63 | id |
| 102 | } | 64 | } |
| 103 | 65 |
crates/onestore/Cargo.toml+6-9| ... | @@ -4,9 +4,6 @@ version = "0.1.0" | ... | @@ -4,9 +4,6 @@ version = "0.1.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | publish = false | 5 | publish = false |
| 6 | 6 | ||
| 7 | [features] | ||
| 8 | protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] | ||
| 9 | |||
| 10 | [dependencies] | 7 | [dependencies] |
| 11 | base64 = "0.22.1" | 8 | base64 = "0.22.1" |
| 12 | bumpalo = "3.20.3" | 9 | bumpalo = "3.20.3" |
| ... | @@ -14,12 +11,12 @@ serde_json = "1.0.151" | ... | @@ -14,12 +11,12 @@ serde_json = "1.0.151" |
| 14 | md5 = "0.8.1" | 11 | md5 = "0.8.1" |
| 15 | getrandom = "0.4.3" | 12 | getrandom = "0.4.3" |
| 16 | serde = { version = "1.0.229", features = ["derive"] } | 13 | serde = { version = "1.0.229", features = ["derive"] } |
| 17 | aes = { version = "0.9.3", features = ["zeroize"], optional = true } | 14 | aes = { version = "0.9.3", features = ["zeroize"] } |
| 18 | cbc = { version = "0.2.1", features = ["zeroize"], optional = true } | 15 | cbc = { version = "0.2.1", features = ["zeroize"] } |
| 19 | roxmltree = { version = "0.21.1", optional = true } | 16 | roxmltree = "0.21.1" |
| 20 | sha1 = { version = "0.11.0", features = ["zeroize"], optional = true } | 17 | sha1 = { version = "0.11.0", features = ["zeroize"] } |
| 21 | subtle = { version = "2.6.1", optional = true } | 18 | subtle = "2.6.1" |
| 22 | zeroize = { version = "1.9.0", optional = true } | 19 | zeroize = "1.9.0" |
| 23 | 20 | ||
| 24 | [target.'cfg(target_os = "macos")'.dependencies] | 21 | [target.'cfg(target_os = "macos")'.dependencies] |
| 25 | nix = { version = "0.31.3", default-features = false, features = ["fs"] } | 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,7 +47,9 @@ harness also accepts `--client-profile release`. |
| 47 | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | | 47 | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | |
| 48 | | `page::Recording`, `page::MediaIndex`, `PageOp::Media` | An audio or video file's recording identity, kind and length, and a paragraph's link to a moment in recordings, as OneNote stores them; the page lists its recordings as they come and go | | 48 | | `page::Recording`, `page::MediaIndex`, `PageOp::Media` | An audio or video file's recording identity, kind and length, and a paragraph's link to a moment in recordings, as OneNote stores them; the page lists its recordings as they come and go | |
| 49 | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points 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` | | 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 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | 53 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 52 | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | | 54 | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | |
| 53 | | `PageEdit`, `SectionOp::Pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | | 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,6 +57,7 @@ harness also accepts `--client-profile release`. |
| 55 | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | 57 | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | |
| 56 | | `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 | | 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 | | `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 | | 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 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | 61 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | | `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 | | 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 | | `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 | | 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,7 +71,8 @@ harness also accepts `--client-profile release`. |
| 68 | | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | | 71 | | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | |
| 69 | | `read_file` | Read a snapshot under whole-file exclusion | | 72 | | `read_file` | Read a snapshot under whole-file exclusion | |
| 70 | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | | 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 | Edits enter a kept-open `Section` as ops and leave as one appended revision per | 77 | Edits enter a kept-open `Section` as ops and leave as one appended revision per |
| 74 | changed space; only section and page creation, imports and opening files handle | 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,14 +82,12 @@ navigation titles update in the same transaction; unsupported fields, |
| 78 | protected objects and split surrogate pairs are | 82 | protected objects and split surrogate pairs are |
| 79 | rejected before writing. Appended snapshots cap revision dependency depth at 512 | 83 | rejected before writing. Appended snapshots cap revision dependency depth at 512 |
| 80 | while retaining historical revisions. TOC snapshots can remap encoded CompactIDs | 84 | while retaining historical revisions. TOC snapshots can remap encoded CompactIDs |
| 81 | without changing their resolved references. Password-protected | 85 | without changing their resolved references. A password-protected section opens with |
| 82 | sections retain their encrypted structure and payloads. With the optional | 86 | `Section::unlock` under the `Key` its password opens, and every revision it seals names |
| 83 | `protected` feature, `protected::UnlockedSection` opens native OneNote 2010 | 87 | the key (MS-ONESTORE 2.5.19 asks that of each revision; OneNote names it only in those |
| 84 | AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect | 88 | without a dependency, and reads both). `Section::open` refuses one. Incorrect |
| 85 | passwords, unsupported protection profiles and work-limit failures remain distinct. | 89 | passwords, unsupported protection profiles and work-limit failures remain distinct |
| 86 | The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them | 90 | (`protected::Error`). |
| 87 | under the section's key (fresh IV per object, payloads under the file IV); `Section` | ||
| 88 | rejects protected sections. | ||
| 89 | `PageCreation::new` appends, or inserts before the first page space of an existing | 91 | `PageCreation::new` appends, or inserts before the first page space of an existing |
| 90 | series. `Some("")` creates an empty title field; `None` omits the title node. | 92 | series. `Some("")` creates an empty title field; `None` omits the title node. |
| 91 | `dated(date, time)` gives the title OneNote 2010's date and time fields showing that | 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,34 +333,44 @@ a dedicated loopback test session; its control JSON selects the successful respo |
| 331 | and occurrence to withhold. The captured trace and result files are the regression | 333 | and occurrence to withhold. The captured trace and result files are the regression |
| 332 | oracle; replaying the native experiments requires the supplied Windows/share setup. | 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 | ```rust,no_run | 351 | ```rust,no_run |
| 337 | # #[cfg(feature = "protected")] | 352 | use onestore::{Arena, Section, protected::{Key, rekey}}; |
| 338 | # fn example() { | 353 | # fn example(image: Vec<u8>, password: &str) -> Result<(), Box<dyn std::error::Error>> { |
| 339 | use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}}; | 354 | let key = Key::open(&image, password)?; |
| 340 | # fn inspect(bytes: &[u8], password: &str) -> Result<(), Box<dyn std::error::Error>> { | 355 | let arena = Arena::default(); |
| 341 | let store = Store::parse(bytes)?; | 356 | let mut section = Section::unlock(&arena, image.clone(), &key)?; |
| 342 | let index = RevisionIndex::parse(&store)?; | 357 | assert!(!section.pages()?.is_empty()); |
| 343 | let unlocked = UnlockedSection::open(&index, password, Limits::default())?; | 358 | let changed = rekey(&image, Some(&key), Some(&Key::new("another password")?))?; |
| 344 | let document = unlocked.document()?; | 359 | # drop(changed); |
| 345 | assert!(!document.pages()?.is_empty()); | ||
| 346 | drop(document); | ||
| 347 | drop(unlocked); | ||
| 348 | # Ok(()) } | 360 | # Ok(()) } |
| 349 | # } | ||
| 350 | ``` | 361 | ``` |
| 351 | 362 | ||
| 352 | This example requires `features = ["protected"]`. The owner retains no password; | 363 | A `Key` holds no password; its key is cleared when its last clone drops. A `Section` |
| 353 | its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed | 364 | decodes into its `Arena`, which is not cleared; drop both when the section locks. |
| 354 | strings, serialized models and exports have their own lifetimes. These copies are | 365 | `UnlockedSection` owns its decoded buffers and clears them on drop; the views it lends |
| 355 | plaintext, and dropping the unlock owner does not clear them. CBC has no general | 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 | ciphertext-authentication guarantee; native read-only hashes and model validation | 368 | ciphertext-authentication guarantee; native read-only hashes and model validation |
| 357 | check the corresponding structure. Internal payloads are decoded; external payload | 369 | check the corresponding structure. Internal payloads are decoded; external payload |
| 358 | references remain references, and their protected decoding is not implemented. | 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 | 372 | For a deliberate plaintext diagnostic export, run the notebook exporter |
| 361 | `--features protected` and pass `--password-file PATH` after the source and optional | 373 | (`examples/document`) with `--password-file PATH` after the source and optional |
| 362 | new output directory. The file contains exact UTF-8 password bytes; no newline is | 374 | new output directory. The file contains exact UTF-8 password bytes; no newline is |
| 363 | removed or Unicode normalization applied. The exporter creates protected exports | 375 | removed or Unicode normalization applied. The exporter creates protected exports |
| 364 | under an owner-only directory on Unix and reports protected external payloads as | 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,11 +4,7 @@ use crate::{ |
| 4 | write::{Commit, LiveRevision, PropertyObject, declared, differing}, | 4 | write::{Commit, LiveRevision, PropertyObject, declared, differing}, |
| 5 | }; | 5 | }; |
| 6 | #[cfg(test)] | 6 | #[cfg(test)] |
| 7 | use crate::{ | 7 | use crate::{RevisionIndex, Store, document::Document, write::chain_depth}; |
| 8 | RevisionIndex, Store, | ||
| 9 | document::{Document, Space}, | ||
| 10 | write::chain_depth, | ||
| 11 | }; | ||
| 12 | use bumpalo::Bump; | 8 | use bumpalo::Bump; |
| 13 | use std::{ | 9 | use std::{ |
| 14 | collections::{BTreeMap, BTreeSet}, | 10 | collections::{BTreeMap, BTreeSet}, |
| ... | @@ -50,7 +46,10 @@ impl<'a> ActivePage<'a> { | ... | @@ -50,7 +46,10 @@ impl<'a> ActivePage<'a> { |
| 50 | let view = document | 46 | let view = document |
| 51 | .spaces | 47 | .spaces |
| 52 | .remove(&space) | 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 | .ok_or(Error { | 53 | .ok_or(Error { |
| 55 | offset: 0, | 54 | offset: 0, |
| 56 | message: "The active page is unavailable", | 55 | message: "The active page is unavailable", |
crates/onestore/src/commit.rs+53-4| ... | @@ -65,9 +65,17 @@ impl FileIo { | ... | @@ -65,9 +65,17 @@ impl FileIo { |
| 65 | } | 65 | } |
| 66 | })?); | 66 | })?); |
| 67 | #[cfg(unix)] | 67 | #[cfg(unix)] |
| 68 | let file = options.open(path)?; | 68 | let file = options.open(path.as_ref())?; |
| 69 | #[cfg(all(unix, not(target_os = "macos")))] | 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 | Ok(Self { | 79 | Ok(Self { |
| 72 | #[cfg(windows)] | 80 | #[cfg(windows)] |
| 73 | locks: onenote_locks(&file, write)?, | 81 | locks: onenote_locks(&file, write)?, |
| ... | @@ -255,6 +263,45 @@ pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitEr | ... | @@ -255,6 +263,45 @@ pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitEr |
| 255 | io.finish(result) | 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 | /// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header | 305 | /// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header |
| 259 | /// version metadata. No revision is added; reread before committing on `base` again. | 306 | /// version metadata. No revision is added; reread before committing on `base` again. |
| 260 | /// The caller must hold OneNote-compatible exclusion and independently establish which | 307 | /// The caller must hold OneNote-compatible exclusion and independently establish which |
| ... | @@ -357,8 +404,10 @@ impl Stamp { | ... | @@ -357,8 +404,10 @@ impl Stamp { |
| 357 | }) | 404 | }) |
| 358 | } | 405 | } |
| 359 | 406 | ||
| 360 | /// Reads the header and probes the length, without reading the body. | 407 | /// Checks that the file `io` reads still has this stamp, reading its header and probing its |
| 361 | fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { | 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 | let mut header = [0; 1024]; | 411 | let mut header = [0; 1024]; |
| 363 | crate::snapshot::read_exact( | 412 | crate::snapshot::read_exact( |
| 364 | &mut |offset, output| io.read_at(offset, output), | 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,6 +615,30 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 615 | } else { | 615 | } else { |
| 616 | None | 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 | let checksum = if is_section { | 642 | let checksum = if is_section { |
| 619 | !crc(u32::MAX, &log, file_type) | 643 | !crc(u32::MAX, &log, file_type) |
| 620 | } else { | 644 | } else { |
| ... | @@ -624,7 +648,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result | ... | @@ -624,7 +648,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 624 | log.extend_from_slice(&checksum.to_le_bytes()); | 648 | log.extend_from_slice(&checksum.to_le_bytes()); |
| 625 | log.extend_from_slice(&u64::MAX.to_le_bytes()); | 649 | log.extend_from_slice(&u64::MAX.to_le_bytes()); |
| 626 | log.extend_from_slice(&0_u32.to_le_bytes()); | 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 | output[..16].copy_from_slice(&[ | 652 | output[..16].copy_from_slice(&[ |
| 629 | 0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0, 0x29, 0x96, | 653 | 0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0, 0x29, 0x96, |
| 630 | 0xd3, | 654 | 0xd3, |
| ... | @@ -648,11 +672,11 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result | ... | @@ -648,11 +672,11 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 648 | output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); | 672 | output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); |
| 649 | } | 673 | } |
| 650 | output[96..100].copy_from_slice(&1_u32.to_le_bytes()); | 674 | output[96..100].copy_from_slice(&1_u32.to_le_bytes()); |
| 651 | output[128..148].copy_from_slice(&placement([0; 16], file_name)); | 675 | output[128..148].copy_from_slice(&placement); |
| 652 | for (at, chunk) in hashed_chunk | 676 | for (at, chunk) in hashed |
| 653 | .map(|chunk| (148, chunk)) | 677 | .map(|chunk| (148, chunk)) |
| 654 | .into_iter() | 678 | .into_iter() |
| 655 | .chain([(160, log_chunk), (172, root_chunk)]) | 679 | .chain([(160, log_chunk), (172, root)]) |
| 656 | { | 680 | { |
| 657 | output[at..at + 8].copy_from_slice(&chunk.offset.to_le_bytes()); | 681 | output[at..at + 8].copy_from_slice(&chunk.offset.to_le_bytes()); |
| 658 | output[at + 8..at + 12].copy_from_slice(&(chunk.length as u32).to_le_bytes()); | 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,8 +686,34 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 662 | output[212..228].copy_from_slice(&fresh_guid()?); | 686 | output[212..228].copy_from_slice(&fresh_guid()?); |
| 663 | output[228..236].copy_from_slice(&1_u64.to_le_bytes()); | 687 | output[228..236].copy_from_slice(&1_u64.to_le_bytes()); |
| 664 | output[236..252].copy_from_slice(&fresh_guid()?); | 688 | output[236..252].copy_from_slice(&fresh_guid()?); |
| 665 | let store = Store::parse(&output)?; | 689 | Ok(()) |
| 666 | RevisionIndex::parse(&store)?.validate_current()?; | 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 | Ok(output) | 717 | Ok(output) |
| 668 | } | 718 | } |
| 669 | 719 |
crates/onestore/src/document.rs-5| ... | @@ -39,11 +39,6 @@ impl<'a> Space<'a> { | ... | @@ -39,11 +39,6 @@ impl<'a> Space<'a> { |
| 39 | pub fn active(&self) -> Option<&Revision<'a>> { | 39 | pub fn active(&self) -> Option<&Revision<'a>> { |
| 40 | self.revisions.get(self.contexts.get(&ExGuid::default())?) | 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 | #[derive(Debug, Clone, Serialize)] | 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,6 +10,7 @@ mod edit; |
| 10 | mod files; | 10 | mod files; |
| 11 | mod flush; | 11 | mod flush; |
| 12 | mod formatting; | 12 | mod formatting; |
| 13 | mod identity; | ||
| 13 | mod insertion; | 14 | mod insertion; |
| 14 | mod objects; | 15 | mod objects; |
| 15 | pub mod op; | 16 | pub mod op; |
| ... | @@ -18,7 +19,6 @@ pub mod page; | ... | @@ -18,7 +19,6 @@ pub mod page; |
| 18 | mod pages; | 19 | mod pages; |
| 19 | mod paragraph; | 20 | mod paragraph; |
| 20 | mod properties; | 21 | mod properties; |
| 21 | #[cfg(feature = "protected")] | ||
| 22 | pub mod protected; | 22 | pub mod protected; |
| 23 | mod revisions; | 23 | mod revisions; |
| 24 | mod section; | 24 | mod section; |
| ... | @@ -33,10 +33,11 @@ mod write; | ... | @@ -33,10 +33,11 @@ mod write; |
| 33 | 33 | ||
| 34 | pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place}; | 34 | pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place}; |
| 35 | #[cfg(any(unix, windows))] | 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 | pub use create::{create_empty_section, create_section, create_table_of_contents}; | 37 | pub use create::{create_empty_section, create_section, create_table_of_contents}; |
| 38 | pub use files::FileDataReference; | 38 | pub use files::FileDataReference; |
| 39 | pub use formatting::{FONT_SIZES, TextAttribute}; | 39 | pub use formatting::{FONT_SIZES, TextAttribute}; |
| 40 | pub use identity::{place_image, reidentify}; | ||
| 40 | pub(crate) use insertion::Insertion; | 41 | pub(crate) use insertion::Insertion; |
| 41 | pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; | 42 | pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; |
| 42 | pub use outline::OutlineEdit; | 43 | pub use outline::OutlineEdit; |
crates/onestore/src/objects.rs+10-1| ... | @@ -190,11 +190,20 @@ impl<'a> Cursor<'a> { | ... | @@ -190,11 +190,20 @@ impl<'a> Cursor<'a> { |
| 190 | 190 | ||
| 191 | impl<'a> RevisionIndex<'a> { | 191 | impl<'a> RevisionIndex<'a> { |
| 192 | pub fn validate_current(&self) -> Result<()> { | 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 | let mut edges = BTreeMap::new(); | 202 | let mut edges = BTreeMap::new(); |
| 194 | let mut incoming = BTreeMap::<_, usize>::new(); | 203 | let mut incoming = BTreeMap::<_, usize>::new(); |
| 195 | for (osid, space) in &self.spaces { | 204 | for (osid, space) in &self.spaces { |
| 196 | for rid in space.labels.values().copied().collect::<BTreeSet<_>>() { | 205 | for rid in space.labels.values().copied().collect::<BTreeSet<_>>() { |
| 197 | let revision = self.resolve(*osid, rid)?; | 206 | let revision = resolve(*osid, rid)?; |
| 198 | let mut targets = BTreeSet::new(); | 207 | let mut targets = BTreeSet::new(); |
| 199 | for oid in revision.reachable()? { | 208 | for oid in revision.reachable()? { |
| 200 | let references = revision.objects[&oid].references()?; | 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,7 +16,7 @@ fn invalid(message: &'static str) -> Error { |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | /// The `<ifndf>{GUID}` form a file-data declaration uses to name a payload in the file-data store. | 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 | let id = ExGuid { guid, n: 0 }.to_string(); | 20 | let id = ExGuid { guid, n: 0 }.to_string(); |
| 21 | format!("<ifndf>{}", id.split(',').next().unwrap()) | 21 | format!("<ifndf>{}", id.split(',').next().unwrap()) |
| 22 | } | 22 | } |
crates/onestore/src/op/lower.rs+1| ... | @@ -44,6 +44,7 @@ pub fn lower( | ... | @@ -44,6 +44,7 @@ pub fn lower( |
| 44 | identity: None, | 44 | identity: None, |
| 45 | created: None, | 45 | created: None, |
| 46 | margin_origin: [0.0; 2], | 46 | margin_origin: [0.0; 2], |
| 47 | rtl: false, | ||
| 47 | color: None, | 48 | color: None, |
| 48 | rule_lines: None, | 49 | rule_lines: None, |
| 49 | objects: vec![PageObject::Outline(Outline { | 50 | objects: vec![PageObject::Outline(Outline { |
crates/onestore/src/page/mod.rs+5| ... | @@ -159,6 +159,9 @@ pub struct Page { | ... | @@ -159,6 +159,9 @@ pub struct Page { |
| 159 | /// FILETIME ticks from the page's TopologyCreationTimeStamp. | 159 | /// FILETIME ticks from the page's TopologyCreationTimeStamp. |
| 160 | pub created: Option<u64>, | 160 | pub created: Option<u64>, |
| 161 | pub margin_origin: [f32; 2], | 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 | /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". | 165 | /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". |
| 163 | #[serde(default)] | 166 | #[serde(default)] |
| 164 | pub color: Option<u32>, | 167 | pub color: Option<u32>, |
| ... | @@ -814,6 +817,7 @@ impl Page { | ... | @@ -814,6 +817,7 @@ impl Page { |
| 814 | let Kind::Page { | 817 | let Kind::Page { |
| 815 | margin_origin_x, | 818 | margin_origin_x, |
| 816 | margin_origin_y, | 819 | margin_origin_y, |
| 820 | rtl, | ||
| 817 | .. | 821 | .. |
| 818 | } = &root.kind | 822 | } = &root.kind |
| 819 | else { | 823 | else { |
| ... | @@ -845,6 +849,7 @@ impl Page { | ... | @@ -845,6 +849,7 @@ impl Page { |
| 845 | margin_origin_x.unwrap_or(1.0_f32 * 36.0), | 849 | margin_origin_x.unwrap_or(1.0_f32 * 36.0), |
| 846 | margin_origin_y.unwrap_or(0.4_f32 * 36.0), | 850 | margin_origin_y.unwrap_or(0.4_f32 * 36.0), |
| 847 | ], | 851 | ], |
| 852 | rtl: rtl.unwrap_or(false), | ||
| 848 | color: root | 853 | color: root |
| 849 | .extra | 854 | .extra |
| 850 | .first() | 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,6 +89,48 @@ fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) { |
| 89 | .expect("block aligned"); | 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 | impl Key { | 134 | impl Key { |
| 93 | pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> { | 135 | pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> { |
| 94 | if data.len() > 65536 || password.len() > 65536 { | 136 | if data.len() > 65536 || password.len() > 65536 { |
| ... | @@ -146,47 +188,83 @@ impl Key { | ... | @@ -146,47 +188,83 @@ impl Key { |
| 146 | let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?); | 188 | let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?); |
| 147 | let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?); | 189 | let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?); |
| 148 | let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?); | 190 | let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?); |
| 149 | let mut hash = Sha1::new(); | 191 | let seed = seed(&salt, password, count); |
| 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 | } | ||
| 161 | for (label, bytes) in [ | 192 | for (label, bytes) in [ |
| 162 | ( | 193 | (VERIFIER_INPUT, verifier.as_mut_slice()), |
| 163 | [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79], | 194 | (VERIFIER_VALUE, expected.as_mut_slice()), |
| 164 | verifier.as_mut_slice(), | 195 | (KEY_VALUE, value.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 | ), | ||
| 174 | ] { | 196 | ] { |
| 175 | let mut hash = Sha1::new(); | 197 | decrypt(&block(&seed, label), &salt, bytes)?; |
| 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)?; | ||
| 180 | } | 198 | } |
| 181 | let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice()))); | 199 | let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice()))); |
| 182 | if !bool::from(actual.as_slice().ct_eq(&expected[..20])) { | 200 | if !bool::from(actual.as_slice().ct_eq(&expected[..20])) { |
| 183 | return Err(Error::PasswordMismatch); | 201 | return Err(Error::PasswordMismatch); |
| 184 | } | 202 | } |
| 185 | let mut hash = Sha1::new(); | 203 | Ok(Self { |
| 186 | hash.update(decoded::<16>(data_key, "saltValue")?); | 204 | value, |
| 187 | hash.update(0_u32.to_le_bytes()); | 205 | file_iv: file_iv(&decoded::<16>(data_key, "saltValue")?), |
| 188 | let file_iv = hash.finalize()[..16].try_into().unwrap(); | 206 | }) |
| 189 | Ok(Self { value, file_iv }) | 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 | pub(super) fn property(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> { | 270 | pub(super) fn property(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> { |
| ... | @@ -246,17 +324,21 @@ impl Key { | ... | @@ -246,17 +324,21 @@ impl Key { |
| 246 | Ok(output) | 324 | Ok(output) |
| 247 | } | 325 | } |
| 248 | 326 | ||
| 249 | /// The stored form of a file payload, the inverse of `file`. | 327 | /// The stored form of a file payload, the inverse of `file`: its length, the bytes and |
| 250 | pub(super) fn seal_file(&self, clear: &[u8]) -> Vec<u8> { | 328 | /// random padding to the block, as OneNote pads. |
| 329 | pub(super) fn seal_file(&self, clear: &[u8]) -> Result<Vec<u8>> { | ||
| 251 | if clear.is_empty() { | 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 | output.extend_from_slice(&(clear.len() as u64).to_le_bytes()); | 335 | output.extend_from_slice(&(clear.len() as u64).to_le_bytes()); |
| 256 | output.extend_from_slice(clear); | 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 | encrypt(&self.value, &self.file_iv, &mut output); | 340 | encrypt(&self.value, &self.file_iv, &mut output); |
| 259 | output | 341 | Ok(output) |
| 260 | } | 342 | } |
| 261 | 343 | ||
| 262 | pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> { | 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,24 +1,26 @@ |
| 1 | //! Explicit, in-memory opening of native OneNote 2010 protected sections. | 1 | //! OneNote 2010's password-protected sections: an Office Agile password wrapper |
| 2 | //! | 2 | //! (MS-OFFCRYPTO; SHA-1, 100,000 rounds) around an AES-128 key that encrypts every |
| 3 | //! AES-128/CBC and SHA-1 Agile password wrappers are supported. Unknown wrappers | 3 | //! property object (CBC, an IV of its own) and payload (CBC, one IV from the key data's |
| 4 | //! remain opaque through the ordinary storage/document APIs. CBC provides no | 4 | //! salt). Unknown wrappers stay opaque. CBC authenticates nothing; read-only hashes and |
| 5 | //! general ciphertext authentication; native read-only hashes and model checks | 5 | //! model checks catch structural damage, not every change to content. |
| 6 | //! detect structural inconsistencies, not arbitrary changes to all content. | ||
| 7 | 6 | ||
| 8 | mod crypto; | 7 | mod crypto; |
| 9 | 8 | ||
| 10 | use crate::{ | 9 | use crate::{ |
| 11 | ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Transaction, | 10 | Chunk, ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Store, |
| 12 | document::Document, | 11 | document::Document, |
| 13 | op::{Edit, Op, OpError}, | ||
| 14 | }; | 12 | }; |
| 13 | use bumpalo::Bump; | ||
| 15 | use std::{ | 14 | use std::{ |
| 15 | cell::RefCell, | ||
| 16 | collections::{BTreeMap, BTreeSet}, | 16 | collections::{BTreeMap, BTreeSet}, |
| 17 | fmt, | 17 | fmt, |
| 18 | sync::Arc, | ||
| 18 | }; | 19 | }; |
| 19 | use zeroize::Zeroizing; | 20 | use zeroize::Zeroizing; |
| 20 | 21 | ||
| 21 | type Result<T> = std::result::Result<T, Error>; | 22 | type Result<T> = std::result::Result<T, Error>; |
| 23 | type Format<T> = std::result::Result<T, crate::Error>; | ||
| 22 | 24 | ||
| 23 | #[derive(Debug)] | 25 | #[derive(Debug)] |
| 24 | pub enum Error { | 26 | pub enum Error { |
| ... | @@ -34,15 +36,34 @@ impl From<crate::Error> for 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 | impl fmt::Display for Error { | 62 | impl fmt::Display for Error { |
| 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | 63 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 39 | match self { | 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 | Self::Invalid(error) => error.fmt(f), | 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,17 +100,10 @@ impl Default for Limits { |
| 79 | } | 100 | } |
| 80 | } | 101 | } |
| 81 | 102 | ||
| 82 | struct Decoded<'a> { | 103 | /// A protected section's labelled revisions decoded for reading as a `Document`, which |
| 83 | stored: &'a [u8], | 104 | /// cannot outlive it. Holds no password; dropping it clears its keys and decoded buffers, |
| 84 | clear: Zeroizing<Vec<u8>>, | 105 | /// while strings and exports made from the `Document` are the caller's to dispose of. |
| 85 | } | 106 | /// Edits go through `Section::unlock`. |
| 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. | ||
| 93 | /// | 107 | /// |
| 94 | /// ```compile_fail | 108 | /// ```compile_fail |
| 95 | /// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}}; | 109 | /// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}}; |
| ... | @@ -103,22 +117,30 @@ struct Decoded<'a> { | ... | @@ -103,22 +117,30 @@ struct Decoded<'a> { |
| 103 | pub struct UnlockedSection<'a> { | 117 | pub struct UnlockedSection<'a> { |
| 104 | index: &'a RevisionIndex<'a>, | 118 | index: &'a RevisionIndex<'a>, |
| 105 | /// By object space and stored address. | 119 | /// By object space and stored address. |
| 106 | objects: BTreeMap<(ExGuid, usize), Decoded<'a>>, | 120 | objects: BTreeMap<(ExGuid, usize), Zeroizing<Vec<u8>>>, |
| 107 | files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>, | 121 | files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>, |
| 108 | keys: BTreeMap<&'a [u8], crypto::Key>, | ||
| 109 | } | 122 | } |
| 110 | 123 | ||
| 111 | impl<'a> UnlockedSection<'a> { | 124 | impl<'a> UnlockedSection<'a> { |
| 112 | /// Verifies the password and every labeled revision before exposing a view. | 125 | /// Verifies the password and every labeled revision before exposing a view. |
| 113 | /// Metadata and password input are each bounded to 64 KiB. | 126 | /// Metadata and password input are each bounded to 64 KiB. |
| 114 | pub fn open(index: &'a RevisionIndex<'a>, password: &str, limits: Limits) -> Result<Self> { | 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 | crypto::Key::open(metadata, password, rounds) | 129 | crypto::Key::open(metadata, password, rounds) |
| 117 | }) | 130 | }) |
| 118 | } | 131 | } |
| 119 | 132 | ||
| 120 | /// `open` with each distinct encryption metadata's key from `key`. | 133 | /// `open` with the key `Key::open` gave, which every space's encryption data must name. |
| 121 | fn unlock( | 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 | index: &'a RevisionIndex<'a>, | 144 | index: &'a RevisionIndex<'a>, |
| 123 | mut limits: Limits, | 145 | mut limits: Limits, |
| 124 | mut key: impl FnMut(&[u8], &mut u64) -> Result<crypto::Key>, | 146 | mut key: impl FnMut(&[u8], &mut u64) -> Result<crypto::Key>, |
| ... | @@ -133,31 +155,10 @@ impl<'a> UnlockedSection<'a> { | ... | @@ -133,31 +155,10 @@ impl<'a> UnlockedSection<'a> { |
| 133 | index, | 155 | index, |
| 134 | objects: BTreeMap::new(), | 156 | objects: BTreeMap::new(), |
| 135 | files: BTreeMap::new(), | 157 | files: BTreeMap::new(), |
| 136 | keys: BTreeMap::new(), | ||
| 137 | }; | 158 | }; |
| 138 | let mut keys = BTreeMap::new(); | 159 | let mut keys = BTreeMap::new(); |
| 139 | let mut file_keys = BTreeMap::new(); | 160 | let mut file_keys = BTreeMap::new(); |
| 140 | let mut hashes = BTreeMap::new(); | 161 | let hashes = hashes(index.store)?; |
| 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 | for (space_id, space) in &index.spaces { | 162 | for (space_id, space) in &index.spaces { |
| 162 | let mut metadata = None; | 163 | let mut metadata = None; |
| 163 | for revision in space.revisions.values() { | 164 | for revision in space.revisions.values() { |
| ... | @@ -203,23 +204,10 @@ impl<'a> UnlockedSection<'a> { | ... | @@ -203,23 +204,10 @@ impl<'a> UnlockedSection<'a> { |
| 203 | .checked_sub(bytes.len()) | 204 | .checked_sub(bytes.len()) |
| 204 | .ok_or(Error::Limit)?; | 205 | .ok_or(Error::Limit)?; |
| 205 | let decoded = key.property(bytes)?; | 206 | let decoded = key.property(bytes)?; |
| 206 | if let Some(expected) = expected { | 207 | if expected.is_some_and(|expected| digest(&decoded) != *expected) { |
| 207 | let mut hash = md5::Context::new(); | 208 | return Err(invalid("Decrypted read-only object hash mismatch")); |
| 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 | } | ||
| 215 | } | 209 | } |
| 216 | result.objects.insert( | 210 | result.objects.insert(identity, decoded); |
| 217 | identity, | ||
| 218 | Decoded { | ||
| 219 | stored: bytes, | ||
| 220 | clear: decoded, | ||
| 221 | }, | ||
| 222 | ); | ||
| 223 | } | 211 | } |
| 224 | ObjectData::File { .. } => { | 212 | ObjectData::File { .. } => { |
| 225 | if let Some(FileDataReference::Internal(guid)) = | 213 | if let Some(FileDataReference::Internal(guid)) = |
| ... | @@ -251,7 +239,6 @@ impl<'a> UnlockedSection<'a> { | ... | @@ -251,7 +239,6 @@ impl<'a> UnlockedSection<'a> { |
| 251 | } | 239 | } |
| 252 | } | 240 | } |
| 253 | } | 241 | } |
| 254 | result.keys = keys; | ||
| 255 | for (space, info) in &index.spaces { | 242 | for (space, info) in &index.spaces { |
| 256 | for rid in info.labels.values().copied().collect::<BTreeSet<_>>() { | 243 | for rid in info.labels.values().copied().collect::<BTreeSet<_>>() { |
| 257 | result.resolve(*space, rid)?.reachable()?; | 244 | result.resolve(*space, rid)?.reachable()?; |
| ... | @@ -276,7 +263,7 @@ impl<'a> UnlockedSection<'a> { | ... | @@ -276,7 +263,7 @@ impl<'a> UnlockedSection<'a> { |
| 276 | offset: 0, | 263 | offset: 0, |
| 277 | message: "Protected object was not decoded", | 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 | Ok(revision) | 269 | Ok(revision) |
| ... | @@ -299,216 +286,345 @@ impl<'a> UnlockedSection<'a> { | ... | @@ -299,216 +286,345 @@ impl<'a> UnlockedSection<'a> { |
| 299 | } | 286 | } |
| 300 | } | 287 | } |
| 301 | 288 | ||
| 302 | impl UnlockedSection<'_> { | 289 | /// A protected section's key, opened with its password or made for a new one. Holds no |
| 303 | /// A plaintext section holding every object space's current revision and payloads | 290 | /// password; the key is cleared when its last clone drops. |
| 304 | /// under their own identities, for writers that read ordinary sections. | 291 | #[derive(Clone)] |
| 305 | fn twin(&self) -> std::result::Result<Zeroizing<Vec<u8>>, crate::Error> { | 292 | pub struct Key { |
| 306 | use crate::write::{PropertyObject, RevisionEdit, append_revisions}; | 293 | key: crypto::Key, |
| 307 | let copy = |space: ExGuid, revision: ExGuid| { | 294 | /// The encryption data (MS-ONESTORE 2.5.19) that opens the key. |
| 308 | let revision = self.resolve(space, revision)?; | 295 | metadata: Arc<[u8]>, |
| 309 | // A revision still declares objects its edits detached; a new one holds live ones. | 296 | } |
| 310 | let live = revision.reachable()?; | 297 | |
| 311 | let mut objects = BTreeMap::new(); | 298 | impl fmt::Debug for Key { |
| 312 | for (id, object) in revision.objects.iter().filter(|(id, _)| live.contains(id)) { | 299 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 313 | let copy = match object.data { | 300 | f.write_str("Key") |
| 314 | ObjectData::File { | 301 | } |
| 315 | reference, | 302 | } |
| 316 | extension, | 303 | |
| 317 | } => { | 304 | impl Key { |
| 318 | let text = |bytes: &[u8]| { | 305 | /// Opens the key of the protected section `image` with `password`. |
| 319 | String::from_utf16( | 306 | pub fn open(image: &[u8], password: &str) -> Result<Self> { |
| 320 | &bytes | 307 | let store = Store::parse(image)?; |
| 321 | .chunks_exact(2) | 308 | let metadata = metadata(&store)?.ok_or(Error::Unsupported)?; |
| 322 | .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) | 309 | Ok(Self { |
| 323 | .collect::<Vec<_>>(), | 310 | key: crypto::Key::open(metadata, password, &mut Limits::default().kdf_rounds)?, |
| 324 | ) | 311 | metadata: Arc::from(metadata), |
| 325 | .map_err(|_| crate::Error { | 312 | }) |
| 326 | offset: 0, | 313 | } |
| 327 | message: "Invalid UTF-16 file-data declaration", | 314 | |
| 328 | }) | 315 | /// A fresh key for `password`, with OneNote 2010's encryption data. |
| 329 | }; | 316 | pub fn new(password: &str) -> Result<Self> { |
| 330 | let mut copy = | 317 | let (key, metadata) = crypto::Key::create(password)?; |
| 331 | PropertyObject::file(*id, &text(reference)?, &text(extension)?)?; | 318 | Ok(Self { |
| 332 | copy.jcid = object.jcid; | 319 | key, |
| 333 | copy.global_ids = std::sync::Arc::clone(&object.global_ids); | 320 | metadata: Arc::from(metadata), |
| 334 | copy | 321 | }) |
| 335 | } | 322 | } |
| 336 | _ => PropertyObject::from_object(object)?, | 323 | |
| 337 | }; | 324 | /// The AES key the section's content is encrypted under, for keys derived from it. |
| 338 | objects.insert(*id, copy); | 325 | pub fn secret(&self) -> &[u8; 16] { |
| 339 | } | 326 | self.key.value() |
| 340 | Ok::<_, crate::Error>((revision.roots, objects)) | 327 | } |
| 341 | }; | 328 | |
| 342 | let payloads: Vec<_> = self | 329 | pub(crate) fn metadata(&self) -> &[u8] { |
| 343 | .files | 330 | &self.metadata |
| 344 | .iter() | 331 | } |
| 345 | .map(|(id, bytes)| (*id, bytes.as_slice())) | 332 | } |
| 346 | .collect(); | 333 | |
| 347 | let current = (ExGuid::default(), 1); | 334 | /// The section `image` written anew, under `new` or in the clear, as OneNote 2010 rewrites |
| 348 | // The scaffold's root space takes the section's identity, then its content. | 335 | /// a section whose password it sets, changes or removes: the file and its object spaces |
| 349 | let mut scaffold = crate::create_section("twin.one", "", "")?; | 336 | /// take new identities, each space keeps its labelled revisions, every one a checkpoint, |
| 350 | let identity = |id: ExGuid| { | 337 | /// and the payloads come along. A protected `image` opens under `key`. |
| 351 | let mut bytes = Vec::new(); | 338 | pub fn rekey(image: &[u8], key: Option<&Key>, new: Option<&Key>) -> Result<Vec<u8>> { |
| 352 | id.encode(&mut bytes); | 339 | let store = Store::parse(image)?; |
| 353 | bytes | 340 | if !store.checksum_mismatches.is_empty() { |
| 354 | }; | 341 | return Err(invalid( |
| 355 | let store = crate::Store::parse(&scaffold)?; | 342 | "Cannot write a file with transaction checksum damage", |
| 356 | let placeholder = identity(RevisionIndex::parse(&store)?.root); | 343 | )); |
| 357 | for at in 0..=scaffold.len() - 20 { | 344 | } |
| 358 | if scaffold[at..at + 20] == placeholder { | 345 | let index = RevisionIndex::parse(&store)?; |
| 359 | scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); | 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, |_| { | 380 | let mut revisions = Vec::new(); |
| 363 | let mut spaces = BTreeMap::new(); | 381 | for (rid, labels) in labelled { |
| 364 | for id in self.index.spaces.keys() { | 382 | let mut resolved = index.resolve(*space, rid)?; |
| 365 | let (roots, objects) = copy(*id, self.index.active(*id)?)?; | 383 | if let Some(opened) = &opened { |
| 366 | let edit = if *id == self.index.root { | 384 | for object in resolved.objects.values_mut() { |
| 367 | RevisionEdit::Label { | 385 | if let ObjectData::Encrypted(stored) = object.data { |
| 368 | context: current.0, | 386 | object.data = ObjectData::Properties(opened.property(&arena, stored)?); |
| 369 | role: current.1, | ||
| 370 | roots, | ||
| 371 | objects, | ||
| 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() { | 390 | let mut objects = BTreeMap::new(); |
| 399 | break; | 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<'_> { | 447 | /// The encryption data every revision of `store` names; none in a section without any. |
| 408 | /// Applies `edit` to this section as `Section::apply` applies it to an ordinary one and | 448 | fn metadata<'a>(store: &Store<'a>) -> Result<Option<&'a [u8]>> { |
| 409 | /// seals the result as one revision per changed space, stored under the section's key. | 449 | let mut found = None; |
| 410 | /// Page ops only: a protected section's page list is not edited. | 450 | for node in store.lists.values().flat_map(|list| &list.nodes) { |
| 411 | pub fn apply(&self, author: &str, edit: &Edit) -> std::result::Result<Transaction, OpError> { | 451 | if node.id != 0x7c { |
| 412 | let failed = |error: crate::Error| OpError::Failed(error); | 452 | continue; |
| 413 | if self.keys.len() != 1 { | ||
| 414 | return Err(OpError::Unsupported( | ||
| 415 | "Only a section under one key takes edits", | ||
| 416 | )); | ||
| 417 | } | 453 | } |
| 418 | if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) { | 454 | let Some(Reference::Data(chunk)) = node.reference else { |
| 419 | return Err(OpError::Unsupported( | 455 | return Err(invalid("Missing encryption key reference")); |
| 420 | "A protected section's page list is not edited", | 456 | }; |
| 421 | )); | 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)?; | 517 | let index = RevisionIndex::parse(store)?; |
| 424 | let arena = crate::Arena::default(); | 518 | if index |
| 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 | ||
| 435 | .spaces | 519 | .spaces |
| 436 | .keys() | 520 | .values() |
| 437 | .map(|space| Ok((*space, applied_index.resolve_active(*space)?))) | 521 | .flat_map(|space| space.revisions.values()) |
| 438 | .collect::<std::result::Result<Vec<_>, crate::Error>>() | 522 | .any(|revision| !revision.encrypted) |
| 439 | .map_err(failed)?; | 523 | { |
| 440 | let edited: Vec<_> = edited | 524 | return Err(invalid( |
| 441 | .iter() | 525 | "A protected section stores a revision in the clear", |
| 442 | .map(|(space, after)| (*space, after)) | 526 | )); |
| 443 | .collect(); | 527 | } |
| 444 | let payloads = crate::write::declared_payloads(&applied_store) | 528 | let chunk = store |
| 445 | .into_iter() | 529 | .lists |
| 446 | .map(|guid| Ok((guid, applied_store.file_data(guid)?))) | 530 | .values() |
| 447 | .collect::<std::result::Result<Vec<_>, crate::Error>>() | 531 | .flat_map(|list| &list.nodes) |
| 448 | .map_err(failed)?; | 532 | .find_map(|node| match node.reference { |
| 449 | let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self)) | 533 | Some(Reference::Data(chunk)) if node.id == 0x7c => Some(chunk), |
| 450 | .map_err(failed)? | 534 | _ => None, |
| 451 | .ok_or(OpError::Unsupported("The edit changes nothing"))?; | 535 | }) |
| 452 | let written = | 536 | .ok_or(Error::Unsupported)?; |
| 453 | crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?; | 537 | Ok(Self { |
| 454 | let store = crate::Store::parse(&written).map_err(failed)?; | 538 | key: key.clone(), |
| 455 | let index = RevisionIndex::parse(&store).map_err(failed)?; | 539 | chunk, |
| 456 | UnlockedSection::unlock(&index, Limits::default(), |metadata, _| { | 540 | hashes: hashes(store)?, |
| 457 | self.keys | 541 | clear: RefCell::default(), |
| 458 | .get(metadata) | 542 | stored: RefCell::default(), |
| 459 | .cloned() | ||
| 460 | .ok_or(Error::PasswordMismatch) | ||
| 461 | }) | 543 | }) |
| 462 | .map_err(|error| match error { | 544 | } |
| 463 | Error::Invalid(error) => failed(error), | 545 | |
| 464 | _ => failed(crate::Error { | 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 | offset: 0, | 567 | offset: 0, |
| 466 | message: "The written section does not unlock under its key", | 568 | message: "Decrypted read-only object hash mismatch", |
| 467 | }), | 569 | }); |
| 468 | })?; | 570 | } |
| 469 | Ok(transaction) | 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<'_> { | 576 | /// The clear bytes of a stored payload, in `arena`. |
| 474 | /// The section key; `apply` admits sections with exactly one. | 577 | pub(crate) fn file(&self, arena: &'a Bump, stored: &[u8]) -> Format<&'a [u8]> { |
| 475 | fn key(&self) -> &crypto::Key { | 578 | Ok(arena.alloc_slice_copy(&self.key.key.file(stored).map_err(crate::Error::from)?)) |
| 476 | self.keys.values().next().expect("one section key") | ||
| 477 | } | 579 | } |
| 478 | } | ||
| 479 | 580 | ||
| 480 | impl crate::write::Protection for UnlockedSection<'_> { | 581 | pub(crate) fn key(&self) -> &Key { |
| 481 | fn resolve( | 582 | &self.key |
| 482 | &self, | ||
| 483 | space: ExGuid, | ||
| 484 | revision: ExGuid, | ||
| 485 | ) -> std::result::Result<crate::ResolvedRevision<'_>, crate::Error> { | ||
| 486 | self.resolve(space, revision) | ||
| 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 | fn stored(&self, clear: &[u8]) -> Option<&[u8]> { | 597 | fn stored(&self, clear: &[u8]) -> Option<&[u8]> { |
| 490 | self.objects | 598 | self.stored.borrow().get(&clear.as_ptr().addr()).copied() |
| 491 | .values() | ||
| 492 | .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr())) | ||
| 493 | .map(|decoded| decoded.stored) | ||
| 494 | } | 599 | } |
| 495 | 600 | ||
| 496 | fn seal_property(&self, clear: &[u8]) -> std::result::Result<Vec<u8>, crate::Error> { | 601 | fn seal_property(&self, clear: &[u8]) -> Format<Vec<u8>> { |
| 497 | let random = crate::Error { | 602 | seal_property(&self.key, clear) |
| 498 | offset: 0, | 603 | } |
| 499 | message: "System random source failed", | 604 | |
| 500 | }; | 605 | fn seal_file(&self, clear: &[u8]) -> Format<Vec<u8>> { |
| 501 | let mut iv = [0; 16]; | 606 | self.key.key.seal_file(clear).map_err(crate::Error::from) |
| 502 | getrandom::fill(&mut iv).map_err(|_| random)?; | 607 | } |
| 503 | self.key() | 608 | |
| 504 | .seal_property(clear, iv) | 609 | fn open_property(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> { |
| 505 | .map_err(|error| match error { | 610 | self.key.key.property(stored).map_err(crate::Error::from) |
| 506 | Error::Invalid(error) => error, | ||
| 507 | _ => random, | ||
| 508 | }) | ||
| 509 | } | 611 | } |
| 510 | 612 | ||
| 511 | fn seal_file(&self, clear: &[u8]) -> Vec<u8> { | 613 | fn open_file(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> { |
| 512 | self.key().seal_file(clear) | 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,9 +7,10 @@ use crate::{ |
| 7 | active::{ActivePage, Changes, Files}, | 7 | active::{ActivePage, Changes, Files}, |
| 8 | document::Revision, | 8 | document::Revision, |
| 9 | page::Page, | 9 | page::Page, |
| 10 | protected::{Key, Opened}, | ||
| 10 | store::StoreState, | 11 | store::StoreState, |
| 11 | write::{ | 12 | write::{ |
| 12 | Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth, | 13 | Appending, Commit, LiveRevision, PropertyObject, Protection, Sealing, Written, chain_depth, |
| 13 | check_transaction, declared, label_payload, node, replacement, unchanged, | 14 | check_transaction, declared, label_payload, node, replacement, unchanged, |
| 14 | }, | 15 | }, |
| 15 | }; | 16 | }; |
| ... | @@ -34,8 +35,8 @@ pub struct Section<'a> { | ... | @@ -34,8 +35,8 @@ pub struct Section<'a> { |
| 34 | /// The in-place writes of the sealed transactions, in order. | 35 | /// The in-place writes of the sealed transactions, in order. |
| 35 | patches: Vec<(u64, Vec<u8>)>, | 36 | patches: Vec<(u64, Vec<u8>)>, |
| 36 | state: StoreState, | 37 | state: StoreState, |
| 37 | /// Payloads the opened images embed, by identity, with the image holding each. | 38 | /// Payloads the opened images embed, by identity, as they read. |
| 38 | files: &'a [([u8; 16], Chunk, &'a [u8])], | 39 | files: &'a [([u8; 16], Result<&'a [u8]>)], |
| 39 | /// Payload identities the file-data store declares. | 40 | /// Payload identities the file-data store declares. |
| 40 | declared: BTreeSet<[u8; 16]>, | 41 | declared: BTreeSet<[u8; 16]>, |
| 41 | spaces: BTreeMap<ExGuid, Space<'a>>, | 42 | spaces: BTreeMap<ExGuid, Space<'a>>, |
| ... | @@ -48,6 +49,8 @@ pub struct Section<'a> { | ... | @@ -48,6 +49,8 @@ pub struct Section<'a> { |
| 48 | root: ExGuid, | 49 | root: ExGuid, |
| 49 | /// While an edit applies, the spaces it changed as they were before it. | 50 | /// While an edit applies, the spaces it changed as they were before it. |
| 50 | undo: Option<Undo<'a>>, | 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 | #[derive(Default)] | 56 | #[derive(Default)] |
| ... | @@ -139,9 +142,17 @@ struct Open<'a> { | ... | @@ -139,9 +142,17 @@ struct Open<'a> { |
| 139 | impl<'a> Section<'a> { | 142 | impl<'a> Section<'a> { |
| 140 | /// Parses and fully validates a section image. | 143 | /// Parses and fully validates a section image. |
| 141 | pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> { | 144 | pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> { |
| 142 | Self::open_with(arena, vec![image], |_, space| { | 145 | Ok(Self::open_with(arena, vec![image], None, current)?) |
| 143 | space.labels.get(&(ExGuid::default(), 1)).copied() | 146 | } |
| 144 | }) | 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 | /// Opens an earlier state of a section: each object space in `revisions` at the revision | 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,7 +164,7 @@ impl<'a> Section<'a> { |
| 153 | images: Vec<Vec<u8>>, | 164 | images: Vec<Vec<u8>>, |
| 154 | revisions: &BTreeMap<ExGuid, ExGuid>, | 165 | revisions: &BTreeMap<ExGuid, ExGuid>, |
| 155 | ) -> Result<Self> { | 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 | revisions | 168 | revisions |
| 158 | .get(id) | 169 | .get(id) |
| 159 | .copied() | 170 | .copied() |
| ... | @@ -170,15 +181,17 @@ impl<'a> Section<'a> { | ... | @@ -170,15 +181,17 @@ impl<'a> Section<'a> { |
| 170 | 181 | ||
| 171 | /// `open`, each object space at the revision `revision` chooses in the first image where it | 182 | /// `open`, each object space at the revision `revision` chooses in the first image where it |
| 172 | /// chooses one; a space it chooses none for is left out. Payloads are read from the image | 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 | fn open_with( | 185 | fn open_with( |
| 175 | arena: &'a Arena, | 186 | arena: &'a Arena, |
| 176 | images: Vec<Vec<u8>>, | 187 | images: Vec<Vec<u8>>, |
| 188 | key: Option<&Key>, | ||
| 177 | revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option<ExGuid>, | 189 | revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option<ExGuid>, |
| 178 | ) -> Result<Self> { | 190 | ) -> std::result::Result<Self, crate::protected::Error> { |
| 179 | let mut opened = None; | 191 | let mut opened = None; |
| 192 | let mut unlocked = None; | ||
| 180 | let mut spaces = BTreeMap::new(); | 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 | for image in images { | 195 | for image in images { |
| 183 | let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); | 196 | let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); |
| 184 | drop(image); | 197 | drop(image); |
| ... | @@ -187,28 +200,40 @@ impl<'a> Section<'a> { | ... | @@ -187,28 +200,40 @@ impl<'a> Section<'a> { |
| 187 | return Err(Error { | 200 | return Err(Error { |
| 188 | offset: *offset, | 201 | offset: *offset, |
| 189 | message: "Cannot write a file with transaction checksum damage", | 202 | message: "Cannot write a file with transaction checksum damage", |
| 190 | }); | 203 | } |
| 204 | .into()); | ||
| 191 | } | 205 | } |
| 192 | if store.header.file_type != FileType::Section { | 206 | if store.header.file_type != FileType::Section { |
| 193 | return Err(Error { | 207 | return Err(Error { |
| 194 | offset: 0, | 208 | offset: 0, |
| 195 | message: "Choose a section file", | 209 | message: "Choose a section file", |
| 196 | }); | 210 | } |
| 211 | .into()); | ||
| 197 | } | 212 | } |
| 198 | let index = RevisionIndex::parse(&store)?; | 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 | for (id, space) in &index.spaces { | 225 | for (id, space) in &index.spaces { |
| 201 | let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else { | 226 | let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else { |
| 202 | continue; | 227 | continue; |
| 203 | }; | 228 | }; |
| 204 | let revision = bound(bytes, index.resolve(*id, rid)?); | 229 | let revision = bound(index.resolve(*id, rid)?)?; |
| 205 | let history = space | 230 | let history = space |
| 206 | .labels | 231 | .labels |
| 207 | .get(&(versions::HISTORY, 1)) | 232 | .get(&(versions::HISTORY, 1)) |
| 208 | .map(|history| -> Result<History<'a>> { | 233 | .map(|history| -> Result<History<'a>> { |
| 209 | Ok(History { | 234 | Ok(History { |
| 210 | rid: *history, | 235 | rid: *history, |
| 211 | revision: bound(bytes, index.resolve(*id, *history)?), | 236 | revision: bound(index.resolve(*id, *history)?)?, |
| 212 | depth: chain_depth(&index, *id, *history), | 237 | depth: chain_depth(&index, *id, *history), |
| 213 | pending: BTreeMap::new(), | 238 | pending: BTreeMap::new(), |
| 214 | }) | 239 | }) |
| ... | @@ -231,11 +256,24 @@ impl<'a> Section<'a> { | ... | @@ -231,11 +256,24 @@ impl<'a> Section<'a> { |
| 231 | return Err(Error { | 256 | return Err(Error { |
| 232 | offset: node.offset, | 257 | offset: node.offset, |
| 233 | message: "File-data object lacks a data reference", | 258 | message: "File-data object lacks a data reference", |
| 234 | }); | 259 | } |
| 260 | .into()); | ||
| 235 | }; | 261 | }; |
| 236 | // A payload an earlier image declares is the same bytes: it names them. | 262 | // A payload an earlier image declares is the same bytes: it names them. |
| 237 | if !files[..known].iter().any(|(declared, ..)| declared == guid) { | 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 | if opened.is_none() { | 279 | if opened.is_none() { |
| ... | @@ -259,6 +297,7 @@ impl<'a> Section<'a> { | ... | @@ -259,6 +297,7 @@ impl<'a> Section<'a> { |
| 259 | broken: false, | 297 | broken: false, |
| 260 | root, | 298 | root, |
| 261 | undo: None, | 299 | undo: None, |
| 300 | unlocked, | ||
| 262 | }) | 301 | }) |
| 263 | } | 302 | } |
| 264 | 303 | ||
| ... | @@ -479,25 +518,14 @@ impl<'a> Section<'a> { | ... | @@ -479,25 +518,14 @@ impl<'a> Section<'a> { |
| 479 | fn files(&self) -> Files<'a> { | 518 | fn files(&self) -> Files<'a> { |
| 480 | let files = self.files; | 519 | let files = self.files; |
| 481 | std::rc::Rc::new(move |guid| { | 520 | std::rc::Rc::new(move |guid| { |
| 482 | let start = files.partition_point(|(id, ..)| *id < guid); | 521 | let start = files.partition_point(|(id, _)| *id < guid); |
| 483 | match files[start..] | 522 | let mut declared = files[start..].iter().take_while(|(id, _)| *id == guid); |
| 484 | .iter() | 523 | match (declared.next(), declared.next()) { |
| 485 | .take_while(|(id, ..)| *id == guid) | 524 | (None, _) => Err(Error { |
| 486 | .count() | ||
| 487 | { | ||
| 488 | 0 => Err(Error { | ||
| 489 | offset: 0, | 525 | offset: 0, |
| 490 | message: "File-data object is not declared", | 526 | message: "File-data object is not declared", |
| 491 | }), | 527 | }), |
| 492 | 1 => { | 528 | (Some((_, payload)), None) => *payload, |
| 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 | } | ||
| 501 | _ => Err(Error { | 529 | _ => Err(Error { |
| 502 | offset: 0, | 530 | offset: 0, |
| 503 | message: "Duplicate file-data identity", | 531 | message: "Duplicate file-data identity", |
| ... | @@ -706,6 +734,10 @@ impl<'a> Section<'a> { | ... | @@ -706,6 +734,10 @@ impl<'a> Section<'a> { |
| 706 | } | 734 | } |
| 707 | self.broken = true; | 735 | self.broken = true; |
| 708 | let arena = &self.arena.0; | 736 | let arena = &self.arena.0; |
| 737 | let protection = self | ||
| 738 | .unlocked | ||
| 739 | .as_ref() | ||
| 740 | .map(|unlocked| unlocked as &dyn Protection); | ||
| 709 | let mut appending = Appending::new(self.state.clone()); | 741 | let mut appending = Appending::new(self.state.clone()); |
| 710 | let mut sealed = Vec::new(); | 742 | let mut sealed = Vec::new(); |
| 711 | for (id, space) in &mut self.spaces { | 743 | for (id, space) in &mut self.spaces { |
| ... | @@ -726,8 +758,7 @@ impl<'a> Section<'a> { | ... | @@ -726,8 +758,7 @@ impl<'a> Section<'a> { |
| 726 | created: &frozen.revised.created, | 758 | created: &frozen.revised.created, |
| 727 | }, | 759 | }, |
| 728 | &self.segments, | 760 | &self.segments, |
| 729 | None, | 761 | protection, |
| 730 | None, | ||
| 731 | &mut manifest, | 762 | &mut manifest, |
| 732 | )?; | 763 | )?; |
| 733 | } | 764 | } |
| ... | @@ -757,8 +788,7 @@ impl<'a> Section<'a> { | ... | @@ -757,8 +788,7 @@ impl<'a> Section<'a> { |
| 757 | created: &created, | 788 | created: &created, |
| 758 | }, | 789 | }, |
| 759 | &self.segments, | 790 | &self.segments, |
| 760 | None, | 791 | protection, |
| 761 | None, | ||
| 762 | &mut manifest, | 792 | &mut manifest, |
| 763 | )?; | 793 | )?; |
| 764 | if commit.checkpoint { | 794 | if commit.checkpoint { |
| ... | @@ -805,8 +835,7 @@ impl<'a> Section<'a> { | ... | @@ -805,8 +835,7 @@ impl<'a> Section<'a> { |
| 805 | created: &created, | 835 | created: &created, |
| 806 | }, | 836 | }, |
| 807 | &self.segments, | 837 | &self.segments, |
| 808 | None, | 838 | protection, |
| 809 | None, | ||
| 810 | &mut manifest, | 839 | &mut manifest, |
| 811 | )?; | 840 | )?; |
| 812 | if commit.checkpoint { | 841 | if commit.checkpoint { |
| ... | @@ -834,7 +863,7 @@ impl<'a> Section<'a> { | ... | @@ -834,7 +863,7 @@ impl<'a> Section<'a> { |
| 834 | .filter(|(guid, _)| !self.declared.contains(guid)) | 863 | .filter(|(guid, _)| !self.declared.contains(guid)) |
| 835 | .copied() | 864 | .copied() |
| 836 | .collect(); | 865 | .collect(); |
| 837 | appending.payloads(&payloads, None)?; | 866 | appending.payloads(&payloads, protection)?; |
| 838 | #[cfg_attr(not(test), expect(unused_mut))] | 867 | #[cfg_attr(not(test), expect(unused_mut))] |
| 839 | let mut transaction = appending.finish()?; | 868 | let mut transaction = appending.finish()?; |
| 840 | #[cfg(test)] | 869 | #[cfg(test)] |
| ... | @@ -892,6 +921,7 @@ impl<'a> Section<'a> { | ... | @@ -892,6 +921,7 @@ impl<'a> Section<'a> { |
| 892 | &written, | 921 | &written, |
| 893 | &labels, | 922 | &labels, |
| 894 | &payloads, | 923 | &payloads, |
| 924 | protection, | ||
| 895 | )?; | 925 | )?; |
| 896 | let base = transaction.base.length; | 926 | let base = transaction.base.length; |
| 897 | let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append); | 927 | let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append); |
| ... | @@ -899,11 +929,20 @@ impl<'a> Section<'a> { | ... | @@ -899,11 +929,20 @@ impl<'a> Section<'a> { |
| 899 | self.patches.extend(transaction.patches.iter().cloned()); | 929 | self.patches.extend(transaction.patches.iter().cloned()); |
| 900 | self.state = state.clone(); | 930 | self.state = state.clone(); |
| 901 | self.declared.extend(payloads.iter().map(|(guid, _)| *guid)); | 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 | for (object_id, chunk) in stored { | 936 | for (object_id, chunk) in stored { |
| 904 | let start = (chunk.offset - base) as usize; | 937 | let start = (chunk.offset - base) as usize; |
| 905 | revision.objects.get_mut(object_id).unwrap().data = | 938 | let bytes = &segment[start..start + chunk.length as usize]; |
| 906 | ObjectData::Properties(&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 | for (id, page, history) in &mut sealed { | 948 | for (id, page, history) in &mut sealed { |
| ... | @@ -916,11 +955,11 @@ impl<'a> Section<'a> { | ... | @@ -916,11 +955,11 @@ impl<'a> Section<'a> { |
| 916 | space.rid = Some(*rid); | 955 | space.rid = Some(*rid); |
| 917 | space.restored = None; | 956 | space.restored = None; |
| 918 | space.newest = Some(*rid); | 957 | space.newest = Some(*rid); |
| 919 | bind(&mut open.file.revision, stored); | 958 | rebind(&mut open.file.revision, stored); |
| 920 | } | 959 | } |
| 921 | if let Some((rid, stored, live, _)) = history.take() { | 960 | if let Some((rid, stored, live, _)) = history.take() { |
| 922 | let mut revision = live.revision; | 961 | let mut revision = live.revision; |
| 923 | bind(&mut revision, &stored); | 962 | rebind(&mut revision, &stored); |
| 924 | space.history = Some(History { | 963 | space.history = Some(History { |
| 925 | rid, | 964 | rid, |
| 926 | revision, | 965 | revision, |
| ... | @@ -969,7 +1008,11 @@ impl<'a> Section<'a> { | ... | @@ -969,7 +1008,11 @@ impl<'a> Section<'a> { |
| 969 | } | 1008 | } |
| 970 | let mut image = self.image(); | 1009 | let mut image = self.image(); |
| 971 | transaction.apply(&mut image)?; | 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 | Ok(()) | 1016 | Ok(()) |
| 974 | } | 1017 | } |
| 975 | } | 1018 | } |
| ... | @@ -1022,37 +1065,50 @@ fn changed<'a>( | ... | @@ -1022,37 +1065,50 @@ fn changed<'a>( |
| 1022 | })) | 1065 | })) |
| 1023 | } | 1066 | } |
| 1024 | 1067 | ||
| 1025 | /// `resolved`, parsed from `bytes`, borrowing them for their lifetime. | 1068 | /// `resolved` with every slice moved into bytes that live as long as the section by `bytes`, |
| 1026 | fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> { | 1069 | /// a protected section's objects decoded under `unlocked`. |
| 1027 | ResolvedRevision { | 1070 | fn bind<'a>( |
| 1028 | roots: resolved.roots, | 1071 | arena: &'a Arena, |
| 1029 | objects: resolved | 1072 | unlocked: Option<&Opened<'a>>, |
| 1030 | .objects | 1073 | resolved: ResolvedRevision<'_>, |
| 1031 | .into_iter() | 1074 | bytes: impl Fn(&[u8]) -> Result<&'a [u8]>, |
| 1032 | .map(|(id, object)| { | 1075 | ) -> Result<ResolvedRevision<'a>> { |
| 1033 | // The parse borrows `bytes` only as long as its store; its slices lie in | 1076 | let mut objects = BTreeMap::new(); |
| 1034 | // `bytes`, which the arena keeps. | 1077 | for (id, object) in resolved.objects { |
| 1035 | let data = match object.data { | 1078 | let data = match (object.data, unlocked) { |
| 1036 | ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)), | 1079 | (ObjectData::Properties(data), _) => ObjectData::Properties(bytes(data)?), |
| 1037 | ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)), | 1080 | (ObjectData::Encrypted(data), Some(unlocked)) => { |
| 1038 | ObjectData::File { | 1081 | ObjectData::Properties(unlocked.property(&arena.0, bytes(data)?)?) |
| 1039 | reference, | 1082 | } |
| 1040 | extension, | 1083 | (ObjectData::Encrypted(data), None) => ObjectData::Encrypted(bytes(data)?), |
| 1041 | } => ObjectData::File { | 1084 | ( |
| 1042 | reference: within(bytes, reference), | 1085 | ObjectData::File { |
| 1043 | extension: within(bytes, extension), | 1086 | reference, |
| 1044 | }, | 1087 | extension, |
| 1045 | }; | 1088 | }, |
| 1046 | let object = crate::Object { | 1089 | _, |
| 1047 | jcid: object.jcid, | 1090 | ) => ObjectData::File { |
| 1048 | reference_count: object.reference_count, | 1091 | reference: bytes(reference)?, |
| 1049 | data, | 1092 | extension: bytes(extension)?, |
| 1050 | global_ids: object.global_ids, | 1093 | }, |
| 1051 | }; | 1094 | }; |
| 1052 | (id, object) | 1095 | let object = crate::Object { |
| 1053 | }) | 1096 | jcid: object.jcid, |
| 1054 | .collect(), | 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 | /// `part`, a slice of `image`, with the image's lifetime. | 1114 | /// `part`, a slice of `image`, with the image's lifetime. |
| ... | @@ -1097,7 +1153,7 @@ mod tests { | ... | @@ -1097,7 +1153,7 @@ mod tests { |
| 1097 | edited: &[(ExGuid, &ResolvedRevision<'_>)], | 1153 | edited: &[(ExGuid, &ResolvedRevision<'_>)], |
| 1098 | payloads: &[([u8; 16], &[u8])], | 1154 | payloads: &[([u8; 16], &[u8])], |
| 1099 | ) -> Vec<u8> { | 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 | crate::write::applied(index.store.data, transaction.as_ref()).unwrap() | 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,7 +6,7 @@ |
| 6 | 6 | ||
| 7 | use super::{Frozen, Open, Section, SpaceState, changed, one_page}; | 7 | use super::{Frozen, Open, Section, SpaceState, changed, one_page}; |
| 8 | use crate::{ | 8 | use crate::{ |
| 9 | Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store, | 9 | Error, ExGuid, FileType, Object, ResolvedRevision, RevisionIndex, Store, |
| 10 | active::{ActivePage, manifest_pages}, | 10 | active::{ActivePage, manifest_pages}, |
| 11 | create::{author_properties, current_timestamps, properties}, | 11 | create::{author_properties, current_timestamps, properties}, |
| 12 | document::{Element, Kind, Revision}, | 12 | document::{Element, Kind, Revision}, |
| ... | @@ -144,8 +144,13 @@ impl<'a> Section<'a> { | ... | @@ -144,8 +144,13 @@ impl<'a> Section<'a> { |
| 144 | let image = self.image(); | 144 | let image = self.image(); |
| 145 | let store = Store::parse(&image)?; | 145 | let store = Store::parse(&image)?; |
| 146 | let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?; | 146 | let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?; |
| 147 | let resolved = self.resident(&image, resolved)?; | ||
| 147 | let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| { | 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 | one_page(&view, &manifest_pages(&view)) | 155 | one_page(&view, &manifest_pages(&view)) |
| 151 | } | 156 | } |
| ... | @@ -258,8 +263,17 @@ impl<'a> Section<'a> { | ... | @@ -258,8 +263,17 @@ impl<'a> Section<'a> { |
| 258 | let store = Store::parse(&image)?; | 263 | let store = Store::parse(&image)?; |
| 259 | let index = RevisionIndex::parse(&store)?; | 264 | let index = RevisionIndex::parse(&store)?; |
| 260 | let rid = self.version_revision(&index, space, context)?; | 265 | let rid = self.version_revision(&index, space, context)?; |
| 261 | let resolved = index.resolve(space, rid)?; | 266 | let revision = self.resident(&image, index.resolve(space, rid)?)?; |
| 262 | // Object bytes lie in the section's segments, which outlive this image. | 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 | let resident = |part: &[u8]| -> Result<&'a [u8]> { | 277 | let resident = |part: &[u8]| -> Result<&'a [u8]> { |
| 264 | let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64; | 278 | let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64; |
| 265 | self.segments | 279 | self.segments |
| ... | @@ -275,34 +289,7 @@ impl<'a> Section<'a> { | ... | @@ -275,34 +289,7 @@ impl<'a> Section<'a> { |
| 275 | message: "A version's bytes lie outside the section", | 289 | message: "A version's bytes lie outside the section", |
| 276 | }) | 290 | }) |
| 277 | }; | 291 | }; |
| 278 | let mut objects = BTreeMap::new(); | 292 | super::bind(self.arena, self.unlocked.as_ref(), resolved, resident) |
| 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))) | ||
| 306 | } | 293 | } |
| 307 | 294 | ||
| 308 | /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the | 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,6 +35,12 @@ pub enum TocEdit { |
| 35 | Remove { | 35 | Remove { |
| 36 | identity: [u8; 16], | 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 | fn component(name: &str) -> bool { | 46 | fn component(name: &str) -> bool { |
| ... | @@ -87,6 +93,7 @@ pub fn edit_table_of_contents( | ... | @@ -87,6 +93,7 @@ pub fn edit_table_of_contents( |
| 87 | stored.into_iter().map(|(_, entry)| entry).collect(); | 93 | stored.into_iter().map(|(_, entry)| entry).collect(); |
| 88 | let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new(); | 94 | let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new(); |
| 89 | let mut renamed = BTreeSet::new(); | 95 | let mut renamed = BTreeSet::new(); |
| 96 | let mut reidentified = BTreeSet::new(); | ||
| 90 | let mut color = None; | 97 | let mut color = None; |
| 91 | for edit in edits { | 98 | for edit in edits { |
| 92 | let position = |identity: &[u8; 16]| { | 99 | let position = |identity: &[u8; 16]| { |
| ... | @@ -178,6 +185,14 @@ pub fn edit_table_of_contents( | ... | @@ -178,6 +185,14 @@ pub fn edit_table_of_contents( |
| 178 | let (id, _, _) = listed.remove(at); | 185 | let (id, _, _) = listed.remove(at); |
| 179 | created.remove(&id); | 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 | let listed = listed; | 198 | let listed = listed; |
| ... | @@ -188,12 +203,12 @@ pub fn edit_table_of_contents( | ... | @@ -188,12 +203,12 @@ pub fn edit_table_of_contents( |
| 188 | TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. } | 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 | let raw = index.resolve_active(space)?; | 207 | let raw = index.resolve_active(space)?; |
| 193 | let mut changed = BTreeMap::new(); | 208 | let mut changed = BTreeMap::new(); |
| 194 | let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; | 209 | let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; |
| 195 | let mut references = Vec::new(); | 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 | let mut object = match created.remove(id) { | 212 | let mut object = match created.remove(id) { |
| 198 | Some(object) => object, | 213 | Some(object) => object, |
| 199 | None => PropertyObject::from_object(&raw.objects[id])?, | 214 | None => PropertyObject::from_object(&raw.objects[id])?, |
| ... | @@ -213,6 +228,9 @@ pub fn edit_table_of_contents( | ... | @@ -213,6 +228,9 @@ pub fn edit_table_of_contents( |
| 213 | if renamed.contains(id) { | 228 | if renamed.contains(id) { |
| 214 | updates.push((0x1c001d6b, crate::create::string(filename))); | 229 | updates.push((0x1c001d6b, crate::create::string(filename))); |
| 215 | } | 230 | } |
| 231 | if reidentified.contains(id) { | ||
| 232 | updates.push((0x1c001d94, identity.to_vec())); | ||
| 233 | } | ||
| 216 | let updates: Vec<(u32, &[u8])> = updates | 234 | let updates: Vec<(u32, &[u8])> = updates |
| 217 | .iter() | 235 | .iter() |
| 218 | .map(|(id, bytes)| (*id, bytes.as_slice())) | 236 | .map(|(id, bytes)| (*id, bytes.as_slice())) |
| ... | @@ -287,7 +305,7 @@ mod tests { | ... | @@ -287,7 +305,7 @@ mod tests { |
| 287 | let index = RevisionIndex::parse(&store).unwrap(); | 305 | let index = RevisionIndex::parse(&store).unwrap(); |
| 288 | let space = Document::parse(&index).unwrap().root; | 306 | let space = Document::parse(&index).unwrap().root; |
| 289 | let listed = entries(&toc); | 307 | let listed = entries(&toc); |
| 290 | let transaction = build_on(&index, &[], None, |index| { | 308 | let transaction = build_on(&index, &[], |index| { |
| 291 | let raw = index.resolve_active(space)?; | 309 | let raw = index.resolve_active(space)?; |
| 292 | let mut changed = BTreeMap::new(); | 310 | let mut changed = BTreeMap::new(); |
| 293 | for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) { | 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,19 +487,11 @@ pub(crate) struct PropertyObject { |
| 487 | 487 | ||
| 488 | pub(crate) enum RevisionEdit { | 488 | pub(crate) enum RevisionEdit { |
| 489 | Update(BTreeMap<ExGuid, PropertyObject>), | 489 | Update(BTreeMap<ExGuid, PropertyObject>), |
| 490 | #[cfg(any(test, feature = "protected"))] | 490 | #[cfg(test)] |
| 491 | Create { | 491 | Create { |
| 492 | roots: BTreeMap<u32, ExGuid>, | 492 | roots: BTreeMap<u32, ExGuid>, |
| 493 | objects: BTreeMap<ExGuid, PropertyObject>, | 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 | impl PropertyObject { | 497 | impl PropertyObject { |
| ... | @@ -801,14 +793,16 @@ 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 | 796 | /// A password-protected section's key, under which a writer stores what it appends. |
| 805 | /// read as plaintext and written back in their stored form. | ||
| 806 | pub(crate) trait Protection { | 797 | pub(crate) trait Protection { |
| 807 | fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result<crate::ResolvedRevision<'_>>; | 798 | /// The stored bytes an object's clear bytes were decoded from. |
| 808 | /// The stored bytes a resolved object's plaintext was decoded from. | ||
| 809 | fn stored(&self, clear: &[u8]) -> Option<&[u8]>; | 799 | fn stored(&self, clear: &[u8]) -> Option<&[u8]>; |
| 810 | fn seal_property(&self, clear: &[u8]) -> Result<Vec<u8>>; | 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 | /// The global-ID entries a group of objects declared together uses: each object's own | 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,7 +1275,7 @@ pub(crate) fn revisions( |
| 1281 | let store = Store::parse(source)?; | 1275 | let store = Store::parse(source)?; |
| 1282 | let index = RevisionIndex::parse(&store)?; | 1276 | let index = RevisionIndex::parse(&store)?; |
| 1283 | index.validate_current()?; | 1277 | index.validate_current()?; |
| 1284 | build_on(&index, &[], None, edit) | 1278 | build_on(&index, &[], edit) |
| 1285 | } | 1279 | } |
| 1286 | 1280 | ||
| 1287 | /// A test fixture: `source` with `edit`'s revisions appended. | 1281 | /// A test fixture: `source` with `edit`'s revisions appended. |
| ... | @@ -1290,32 +1284,17 @@ pub(crate) fn write_revisions( | ... | @@ -1290,32 +1284,17 @@ pub(crate) fn write_revisions( |
| 1290 | source: &[u8], | 1284 | source: &[u8], |
| 1291 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, | 1285 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1292 | ) -> Result<Vec<u8>> { | 1286 | ) -> Result<Vec<u8>> { |
| 1293 | publish(source, &[], None, true, edit) | 1287 | publish(source, 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) | ||
| 1307 | } | 1288 | } |
| 1308 | 1289 | ||
| 1309 | #[cfg(any(test, feature = "protected"))] | 1290 | #[cfg(test)] |
| 1310 | fn publish( | 1291 | fn publish( |
| 1311 | source: &[u8], | 1292 | source: &[u8], |
| 1312 | payloads: &[([u8; 16], &[u8])], | ||
| 1313 | protection: Option<&dyn Protection>, | ||
| 1314 | validate: bool, | 1293 | validate: bool, |
| 1315 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, | 1294 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1316 | ) -> Result<Vec<u8>> { | 1295 | ) -> Result<Vec<u8>> { |
| 1317 | // The parsed source is released before the result is parsed. | 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 | check(&output, validate)?; | 1298 | check(&output, validate)?; |
| 1320 | Ok(output) | 1299 | Ok(output) |
| 1321 | } | 1300 | } |
| ... | @@ -1332,11 +1311,9 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> { | ... | @@ -1332,11 +1311,9 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> { |
| 1332 | 1311 | ||
| 1333 | /// The written image, unchecked: `check` follows once the caller has released whatever | 1312 | /// The written image, unchecked: `check` follows once the caller has released whatever |
| 1334 | /// `edit` borrowed. | 1313 | /// `edit` borrowed. |
| 1335 | #[cfg(any(test, feature = "protected"))] | 1314 | #[cfg(test)] |
| 1336 | fn build( | 1315 | fn build( |
| 1337 | source: &[u8], | 1316 | source: &[u8], |
| 1338 | payloads: &[([u8; 16], &[u8])], | ||
| 1339 | protection: Option<&dyn Protection>, | ||
| 1340 | validate: bool, | 1317 | validate: bool, |
| 1341 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, | 1318 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1342 | ) -> Result<Vec<u8>> { | 1319 | ) -> Result<Vec<u8>> { |
| ... | @@ -1345,10 +1322,7 @@ fn build( | ... | @@ -1345,10 +1322,7 @@ fn build( |
| 1345 | if validate { | 1322 | if validate { |
| 1346 | index.validate_current()?; | 1323 | index.validate_current()?; |
| 1347 | } | 1324 | } |
| 1348 | applied( | 1325 | applied(source, build_on(&index, &[], edit)?.as_ref()) |
| 1349 | source, | ||
| 1350 | build_on(&index, payloads, protection, edit)?.as_ref(), | ||
| 1351 | ) | ||
| 1352 | } | 1326 | } |
| 1353 | 1327 | ||
| 1354 | /// `write_revision` on a source the caller has parsed and validated. | 1328 | /// `write_revision` on a source the caller has parsed and validated. |
| ... | @@ -1360,7 +1334,7 @@ pub(crate) fn write_revision_on( | ... | @@ -1360,7 +1334,7 @@ pub(crate) fn write_revision_on( |
| 1360 | ) -> Result<Vec<u8>> { | 1334 | ) -> Result<Vec<u8>> { |
| 1361 | let output = applied( | 1335 | let output = applied( |
| 1362 | index.store.data, | 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 | check(&output, true)?; | 1339 | check(&output, true)?; |
| 1366 | Ok(output) | 1340 | Ok(output) |
| ... | @@ -1377,7 +1351,6 @@ thread_local! { | ... | @@ -1377,7 +1351,6 @@ thread_local! { |
| 1377 | pub(crate) fn build_on( | 1351 | pub(crate) fn build_on( |
| 1378 | index: &RevisionIndex<'_>, | 1352 | index: &RevisionIndex<'_>, |
| 1379 | payloads: &[([u8; 16], &[u8])], | 1353 | payloads: &[([u8; 16], &[u8])], |
| 1380 | protection: Option<&dyn Protection>, | ||
| 1381 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, | 1354 | edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>, |
| 1382 | ) -> Result<Option<crate::Transaction>> { | 1355 | ) -> Result<Option<crate::Transaction>> { |
| 1383 | #[cfg(test)] | 1356 | #[cfg(test)] |
| ... | @@ -1391,10 +1364,6 @@ pub(crate) fn build_on( | ... | @@ -1391,10 +1364,6 @@ pub(crate) fn build_on( |
| 1391 | message: "Cannot write a file with transaction checksum damage", | 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 | let changes = edit(index)?; | 1367 | let changes = edit(index)?; |
| 1399 | let mut appending = Appending::new(store.state()?); | 1368 | let mut appending = Appending::new(store.state()?); |
| 1400 | for (space, change) in changes { | 1369 | for (space, change) in changes { |
| ... | @@ -1402,9 +1371,15 @@ pub(crate) fn build_on( | ... | @@ -1402,9 +1371,15 @@ pub(crate) fn build_on( |
| 1402 | let (rid, label, revision, replacements, new_space) = match change { | 1371 | let (rid, label, revision, replacements, new_space) = match change { |
| 1403 | RevisionEdit::Update(objects) => { | 1372 | RevisionEdit::Update(objects) => { |
| 1404 | let rid = index.active(space)?; | 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 | RevisionEdit::Create { roots, objects } => { | 1383 | RevisionEdit::Create { roots, objects } => { |
| 1409 | if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { | 1384 | if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { |
| 1410 | return Err(Error { | 1385 | return Err(Error { |
| ... | @@ -1429,30 +1404,6 @@ pub(crate) fn build_on( | ... | @@ -1429,30 +1404,6 @@ pub(crate) fn build_on( |
| 1429 | true, | 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 | let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); | 1408 | let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); |
| 1458 | let mut live = LiveRevision::new(revision, depth)?; | 1409 | let mut live = LiveRevision::new(revision, depth)?; |
| ... | @@ -1478,25 +1429,6 @@ pub(crate) fn build_on( | ... | @@ -1478,25 +1429,6 @@ pub(crate) fn build_on( |
| 1478 | }) | 1429 | }) |
| 1479 | .collect::<Result<Vec<_>>>()?, | 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 | appending.revision( | 1432 | appending.revision( |
| 1501 | &Sealing { | 1433 | &Sealing { |
| 1502 | space, | 1434 | space, |
| ... | @@ -1510,11 +1442,10 @@ pub(crate) fn build_on( | ... | @@ -1510,11 +1442,10 @@ pub(crate) fn build_on( |
| 1510 | created: &created, | 1442 | created: &created, |
| 1511 | }, | 1443 | }, |
| 1512 | &[(0, source)], | 1444 | &[(0, source)], |
| 1513 | protection, | 1445 | None, |
| 1514 | key, | ||
| 1515 | )?; | 1446 | )?; |
| 1516 | } | 1447 | } |
| 1517 | appending.payloads(payloads, protection)?; | 1448 | appending.payloads(payloads, None)?; |
| 1518 | Ok(appending.finish()?.map(|(transaction, _)| transaction)) | 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,7 +1459,6 @@ pub(crate) fn applied(source: &[u8], transaction: Option<&crate::Transaction>) - |
| 1528 | } | 1459 | } |
| 1529 | 1460 | ||
| 1530 | /// Payload identities the file-data store of `store` declares, in order. | 1461 | /// Payload identities the file-data store of `store` declares, in order. |
| 1531 | #[cfg(any(test, feature = "protected"))] | ||
| 1532 | pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { | 1462 | pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { |
| 1533 | store | 1463 | store |
| 1534 | .lists | 1464 | .lists |
| ... | @@ -1541,13 +1471,12 @@ pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { | ... | @@ -1541,13 +1471,12 @@ pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { |
| 1541 | 1471 | ||
| 1542 | /// Writes the live objects of each `edited` revision that differ from its space's active | 1472 | /// Writes the live objects of each `edited` revision that differ from its space's active |
| 1543 | /// revision in the validated `source` as one revision per space, embedding the `payloads` | 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. | 1474 | /// it lacks. |
| 1545 | #[cfg(any(test, feature = "protected"))] | 1475 | #[cfg(test)] |
| 1546 | pub(crate) fn squash( | 1476 | pub(crate) fn squash( |
| 1547 | source: &RevisionIndex<'_>, | 1477 | source: &RevisionIndex<'_>, |
| 1548 | edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], | 1478 | edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], |
| 1549 | payloads: &[([u8; 16], &[u8])], | 1479 | payloads: &[([u8; 16], &[u8])], |
| 1550 | protection: Option<&dyn Protection>, | ||
| 1551 | ) -> Result<Option<crate::Transaction>> { | 1480 | ) -> Result<Option<crate::Transaction>> { |
| 1552 | let existing = declared_payloads(source.store); | 1481 | let existing = declared_payloads(source.store); |
| 1553 | let payloads: Vec<_> = payloads | 1482 | let payloads: Vec<_> = payloads |
| ... | @@ -1555,13 +1484,10 @@ pub(crate) fn squash( | ... | @@ -1555,13 +1484,10 @@ pub(crate) fn squash( |
| 1555 | .filter(|(guid, _)| !existing.contains(guid)) | 1484 | .filter(|(guid, _)| !existing.contains(guid)) |
| 1556 | .copied() | 1485 | .copied() |
| 1557 | .collect(); | 1486 | .collect(); |
| 1558 | build_on(source, &payloads, protection, |index| { | 1487 | build_on(source, &payloads, |index| { |
| 1559 | let mut changes = BTreeMap::new(); | 1488 | let mut changes = BTreeMap::new(); |
| 1560 | for (space, after) in edited { | 1489 | for (space, after) in edited { |
| 1561 | let before = match protection { | 1490 | let before = index.resolve_active(*space)?; |
| 1562 | Some(protection) => protection.resolve(*space, index.active(*space)?)?, | ||
| 1563 | None => index.resolve_active(*space)?, | ||
| 1564 | }; | ||
| 1565 | if before.roots != after.roots { | 1491 | if before.roots != after.roots { |
| 1566 | return Err(Error { | 1492 | return Err(Error { |
| 1567 | offset: 0, | 1493 | offset: 0, |
| ... | @@ -1590,6 +1516,111 @@ pub(crate) fn squash( | ... | @@ -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 | /// A space's revision as `LiveRevision::commit` left it, ready to append. | 1624 | /// A space's revision as `LiveRevision::commit` left it, ready to append. |
| 1594 | pub(crate) struct Sealing<'r, 'a> { | 1625 | pub(crate) struct Sealing<'r, 'a> { |
| 1595 | pub space: ExGuid, | 1626 | pub space: ExGuid, |
| ... | @@ -1664,7 +1695,7 @@ impl Appending { | ... | @@ -1664,7 +1695,7 @@ impl Appending { |
| 1664 | self.base.length + self.append.len() as u64 | 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 | let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { | 1699 | let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { |
| 1669 | offset: 0, | 1700 | offset: 0, |
| 1670 | message: "Chunk exceeds the encoded length limit", | 1701 | message: "Chunk exceeds the encoded length limit", |
| ... | @@ -1743,10 +1774,9 @@ impl Appending { | ... | @@ -1743,10 +1774,9 @@ impl Appending { |
| 1743 | sealing: &Sealing<'_, '_>, | 1774 | sealing: &Sealing<'_, '_>, |
| 1744 | segments: &[(u64, &[u8])], | 1775 | segments: &[(u64, &[u8])], |
| 1745 | protection: Option<&dyn Protection>, | 1776 | protection: Option<&dyn Protection>, |
| 1746 | key: Option<&crate::Node<'_>>, | ||
| 1747 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { | 1777 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { |
| 1748 | let mut manifest = Vec::new(); | 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 | self.close(sealing.space, sealing.new_space, &manifest)?; | 1780 | self.close(sealing.space, sealing.new_space, &manifest)?; |
| 1751 | Ok(written) | 1781 | Ok(written) |
| 1752 | } | 1782 | } |
| ... | @@ -1759,7 +1789,6 @@ impl Appending { | ... | @@ -1759,7 +1789,6 @@ impl Appending { |
| 1759 | sealing: &Sealing<'_, '_>, | 1789 | sealing: &Sealing<'_, '_>, |
| 1760 | segments: &[(u64, &[u8])], | 1790 | segments: &[(u64, &[u8])], |
| 1761 | protection: Option<&dyn Protection>, | 1791 | protection: Option<&dyn Protection>, |
| 1762 | key: Option<&crate::Node<'_>>, | ||
| 1763 | manifest: &mut Vec<Vec<u8>>, | 1792 | manifest: &mut Vec<Vec<u8>>, |
| 1764 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { | 1793 | ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { |
| 1765 | let is_section = self.state.file_type == FileType::Section; | 1794 | let is_section = self.state.file_type == FileType::Section; |
| ... | @@ -1863,12 +1892,14 @@ impl Appending { | ... | @@ -1863,12 +1892,14 @@ impl Appending { |
| 1863 | None, | 1892 | None, |
| 1864 | &start, | 1893 | &start, |
| 1865 | )?); | 1894 | )?); |
| 1866 | if protection.is_some() && checkpoint { | 1895 | // MS-ONESTORE 2.5.19 has every revision of a protected space name its key; OneNote |
| 1867 | let key = key.ok_or(Error { | 1896 | // names it only in revisions without a dependency, and reads either. |
| 1868 | offset: 0, | 1897 | if let Some(protection) = protection { |
| 1869 | message: "Protected revisions continue a protected object space", | 1898 | manifest.push(node( |
| 1870 | })?; | 1899 | 0x7c, |
| 1871 | manifest.push(node(0x7c, key.reference, key.payload)?); | 1900 | Some(Reference::Data(protection.metadata())), |
| 1901 | &[], | ||
| 1902 | )?); | ||
| 1872 | } | 1903 | } |
| 1873 | let mut stored = Vec::new(); | 1904 | let mut stored = Vec::new(); |
| 1874 | for (table, objects) in groups { | 1905 | for (table, objects) in groups { |
| ... | @@ -2084,7 +2115,9 @@ impl Appending { | ... | @@ -2084,7 +2115,9 @@ impl Appending { |
| 2084 | 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, | 2115 | 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, |
| 2085 | 0x9e, 0xac, | 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 | let payload = sealed.as_deref().unwrap_or(*payload); | 2121 | let payload = sealed.as_deref().unwrap_or(*payload); |
| 2089 | blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); | 2122 | blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); |
| 2090 | blob.extend_from_slice(&[0; 12]); | 2123 | blob.extend_from_slice(&[0; 12]); |
| ... | @@ -2279,6 +2312,7 @@ pub(crate) struct Written<'r, 'a> { | ... | @@ -2279,6 +2312,7 @@ pub(crate) struct Written<'r, 'a> { |
| 2279 | /// object parses, names identities its group's table holds and objects reachable after its | 2312 | /// object parses, names identities its group's table holds and objects reachable after its |
| 2280 | /// revision, carries its incremental reference count and, if read-only, its MD5; roots stay | 2313 | /// revision, carries its incremental reference count and, if read-only, its MD5; roots stay |
| 2281 | /// unless a space is new. Validates nothing the transaction leaves as it was. | 2314 | /// unless a space is new. Validates nothing the transaction leaves as it was. |
| 2315 | #[allow(clippy::too_many_arguments)] | ||
| 2282 | pub(crate) fn check_transaction( | 2316 | pub(crate) fn check_transaction( |
| 2283 | before: &StoreState, | 2317 | before: &StoreState, |
| 2284 | after: &StoreState, | 2318 | after: &StoreState, |
| ... | @@ -2287,8 +2321,10 @@ pub(crate) fn check_transaction( | ... | @@ -2287,8 +2321,10 @@ pub(crate) fn check_transaction( |
| 2287 | revisions: &[Written<'_, '_>], | 2321 | revisions: &[Written<'_, '_>], |
| 2288 | labels: &[(ExGuid, ExGuid, ExGuid)], | 2322 | labels: &[(ExGuid, ExGuid, ExGuid)], |
| 2289 | payloads: &[([u8; 16], &[u8])], | 2323 | payloads: &[([u8; 16], &[u8])], |
| 2324 | protection: Option<&dyn Protection>, | ||
| 2290 | ) -> Result<()> { | 2325 | ) -> Result<()> { |
| 2291 | let wrong = |message| Error { offset: 0, message }; | 2326 | let wrong = |message| Error { offset: 0, message }; |
| 2327 | let encoding: [u8; 2] = if protection.is_some() { [2, 0] } else { [0, 0] }; | ||
| 2292 | let base = transaction.base.length; | 2328 | let base = transaction.base.length; |
| 2293 | // A chunk's bytes with this transaction's writes: nothing read here is patched earlier. | 2329 | // A chunk's bytes with this transaction's writes: nothing read here is patched earlier. |
| 2294 | let read = |chunk: Chunk| -> Result<Vec<u8>> { | 2330 | let read = |chunk: Chunk| -> Result<Vec<u8>> { |
| ... | @@ -2449,13 +2485,20 @@ pub(crate) fn check_transaction( | ... | @@ -2449,13 +2485,20 @@ pub(crate) fn check_transaction( |
| 2449 | || c.exguid()? != rid | 2485 | || c.exguid()? != rid |
| 2450 | || c.exguid()? != dependency | 2486 | || c.exguid()? != dependency |
| 2451 | || c.read::<4>()? != label.1.to_le_bytes() | 2487 | || c.read::<4>()? != label.1.to_le_bytes() |
| 2452 | || c.read::<2>()? != [0, 0] | 2488 | || c.read::<2>()? != encoding |
| 2453 | || (contextual && c.exguid()? != label.0) | 2489 | || (contextual && c.exguid()? != label.0) |
| 2454 | { | 2490 | { |
| 2455 | return Err(wrong( | 2491 | return Err(wrong( |
| 2456 | "A revision starts with the wrong identity, dependency or label", | 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 | let mut declared = BTreeSet::new(); | 2502 | let mut declared = BTreeSet::new(); |
| 2460 | let mut roots = BTreeMap::new(); | 2503 | let mut roots = BTreeMap::new(); |
| 2461 | loop { | 2504 | loop { |
| ... | @@ -2513,10 +2556,14 @@ pub(crate) fn check_transaction( | ... | @@ -2513,10 +2556,14 @@ pub(crate) fn check_transaction( |
| 2513 | return Err(wrong("An object declaration lacks its data")); | 2556 | return Err(wrong("An object declaration lacks its data")); |
| 2514 | }; | 2557 | }; |
| 2515 | let data = read(at)?; | 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 | let stored = crate::Object { | 2563 | let stored = crate::Object { |
| 2517 | jcid, | 2564 | jcid, |
| 2518 | reference_count: count, | 2565 | reference_count: count, |
| 2519 | data: ObjectData::Properties(&data), | 2566 | data: ObjectData::Properties(&clear), |
| 2520 | global_ids: Arc::new(table.clone()), | 2567 | global_ids: Arc::new(table.clone()), |
| 2521 | }; | 2568 | }; |
| 2522 | let references = stored.references()?; | 2569 | let references = stored.references()?; |
| ... | @@ -2525,11 +2572,14 @@ pub(crate) fn check_transaction( | ... | @@ -2525,11 +2572,14 @@ pub(crate) fn check_transaction( |
| 2525 | !references.object_spaces.is_empty() | 2572 | !references.object_spaces.is_empty() |
| 2526 | || !references.contexts.is_empty(), | 2573 | || !references.contexts.is_empty(), |
| 2527 | ) << 1); | 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 | || flags != Some(expected) | 2580 | || flags != Some(expected) |
| 2530 | || (item.id == 0xc5) != (jcid & 0x100000 != 0) | 2581 | || (item.id == 0xc5) != (jcid & 0x100000 != 0) |
| 2531 | || (item.id == 0xc5 | 2582 | || (item.id == 0xc5 && c.read::<16>()? != hash) |
| 2532 | && c.read::<16>()? != md5::compute(&data).0) | ||
| 2533 | { | 2583 | { |
| 2534 | return Err(wrong( | 2584 | return Err(wrong( |
| 2535 | "An object's stored bytes differ from its revision", | 2585 | "An object's stored bytes differ from its revision", |
| ... | @@ -2640,8 +2690,12 @@ pub(crate) fn check_transaction( | ... | @@ -2640,8 +2690,12 @@ pub(crate) fn check_transaction( |
| 2640 | return Err(wrong("A payload declaration lacks its data")); | 2690 | return Err(wrong("A payload declaration lacks its data")); |
| 2641 | }; | 2691 | }; |
| 2642 | let data = read(at)?; | 2692 | let data = read(at)?; |
| 2643 | let blob = crate::files::payload(&data, at.offset as usize); | 2693 | let blob = crate::files::payload(&data, at.offset as usize)?; |
| 2644 | if node.id != 0x94 || node.payload != guid || blob.ok() != Some(*payload) { | 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 | return Err(wrong("An embedded payload differs from its declaration")); | 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,52 +1,54 @@ |
| 1 | #![cfg(feature = "protected")] | ||
| 2 | |||
| 3 | use onestore::{ | 1 | use onestore::{ |
| 4 | RevisionIndex, Store, | 2 | Arena, ExGuid, RevisionIndex, Section, Store, |
| 5 | document::{Document, Kind}, | 3 | document::{Document, Kind}, |
| 6 | op::{Edit, Op, PageOp}, | 4 | op::{Edit, Op}, |
| 7 | protected::{Error, Limits, UnlockedSection}, | 5 | page::Page, |
| 6 | protected::{Error, Key, Limits, UnlockedSection, rekey}, | ||
| 8 | }; | 7 | }; |
| 9 | use std::{fs, path::Path}; | 8 | use std::{fs, path::Path}; |
| 10 | 9 | ||
| 11 | #[test] | 10 | /// The native fixtures with their passwords. |
| 12 | fn known_passwords_open_independent_native_fixtures() { | 11 | fn fixtures() -> Vec<(Vec<u8>, String)> { |
| 13 | for (root, notebook, pages) in [ | 12 | [ |
| 14 | ("native-encrypted", "encrypted-01/notebook/synthetic.one", 1), | 13 | ("native-encrypted", "encrypted-01/notebook/synthetic.one"), |
| 15 | ("native-protected-boundaries", "notebook/synthetic.one", 11), | 14 | ("native-protected-boundaries", "notebook/synthetic.one"), |
| 16 | ] { | 15 | ] |
| 16 | .into_iter() | ||
| 17 | .map(|(root, notebook)| { | ||
| 17 | let root = Path::new("../../corpus").join(root); | 18 | let root = Path::new("../../corpus").join(root); |
| 18 | let manifest: serde_json::Value = | 19 | let manifest: serde_json::Value = |
| 19 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); | 20 | serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); |
| 20 | let password = manifest["password"].as_str().unwrap(); | 21 | ( |
| 21 | let bytes = fs::read(root.join(notebook)).unwrap(); | 22 | fs::read(root.join(notebook)).unwrap(), |
| 22 | let before = bytes.clone(); | 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 | let store = Store::parse(&bytes).unwrap(); | 46 | let store = Store::parse(&bytes).unwrap(); |
| 24 | let index = RevisionIndex::parse(&store).unwrap(); | 47 | let index = RevisionIndex::parse(&store).unwrap(); |
| 25 | assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); | 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 | let document = unlocked.document().unwrap(); | 50 | let document = unlocked.document().unwrap(); |
| 28 | assert_eq!(document.pages().unwrap().len(), pages); | 51 | assert_eq!(document.pages().unwrap().len(), count); |
| 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()); | ||
| 50 | assert!( | 52 | assert!( |
| 51 | document | 53 | document |
| 52 | .spaces | 54 | .spaces |
| ... | @@ -55,26 +57,26 @@ fn known_passwords_open_independent_native_fixtures() { | ... | @@ -55,26 +57,26 @@ fn known_passwords_open_independent_native_fixtures() { |
| 55 | .flat_map(|r| r.nodes.values()) | 57 | .flat_map(|r| r.nodes.values()) |
| 56 | .all(|n| !matches!(n.kind, Kind::Encrypted { .. })) | 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 | assert!(matches!( | 70 | assert!(matches!( |
| 59 | UnlockedSection::open( | 71 | Key::open(&bytes, "deliberately incorrect fixture password"), |
| 60 | &index, | ||
| 61 | "deliberately incorrect fixture password", | ||
| 62 | Limits::default() | ||
| 63 | ), | ||
| 64 | Err(Error::PasswordMismatch) | 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 | #[test] | 77 | #[test] |
| 72 | fn limits_and_password_bytes_are_explicit() { | 78 | fn limits_and_password_bytes_are_explicit() { |
| 73 | let root = Path::new("../../corpus/native-protected-boundaries"); | 79 | let (bytes, password) = fixtures().remove(1); |
| 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(); | ||
| 78 | let store = Store::parse(&bytes).unwrap(); | 80 | let store = Store::parse(&bytes).unwrap(); |
| 79 | let index = RevisionIndex::parse(&store).unwrap(); | 81 | let index = RevisionIndex::parse(&store).unwrap(); |
| 80 | for limits in [ | 82 | for limits in [ |
| ... | @@ -92,7 +94,7 @@ fn limits_and_password_bytes_are_explicit() { | ... | @@ -92,7 +94,7 @@ fn limits_and_password_bytes_are_explicit() { |
| 92 | }, | 94 | }, |
| 93 | ] { | 95 | ] { |
| 94 | assert!(matches!( | 96 | assert!(matches!( |
| 95 | UnlockedSection::open(&index, password, limits), | 97 | UnlockedSection::open(&index, &password, limits), |
| 96 | Err(Error::Limit) | 98 | Err(Error::Limit) |
| 97 | )); | 99 | )); |
| 98 | } | 100 | } |
| ... | @@ -102,111 +104,111 @@ fn limits_and_password_bytes_are_explicit() { | ... | @@ -102,111 +104,111 @@ fn limits_and_password_bytes_are_explicit() { |
| 102 | password.to_uppercase(), | 104 | password.to_uppercase(), |
| 103 | ] { | 105 | ] { |
| 104 | assert!(matches!( | 106 | assert!(matches!( |
| 105 | UnlockedSection::open(&index, &changed, Limits::default()), | 107 | Key::open(&bytes, &changed), |
| 106 | Err(Error::PasswordMismatch) | 108 | Err(Error::PasswordMismatch) |
| 107 | )); | 109 | )); |
| 108 | } | 110 | } |
| 109 | assert!(matches!( | 111 | assert!(matches!( |
| 110 | UnlockedSection::open(&index, &"x".repeat(65537), Limits::default()), | 112 | Key::open(&bytes, &"x".repeat(65537)), |
| 111 | Err(Error::Limit) | 113 | Err(Error::Limit) |
| 112 | )); | 114 | )); |
| 113 | let ordinary = onestore::create_section("ordinary.one", "Fictitious", "Author").unwrap(); | 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 | assert!(matches!( | 116 | assert!(matches!( |
| 117 | UnlockedSection::open(&index, password, Limits::default()), | 117 | Key::open(&ordinary, &password), |
| 118 | Err(Error::Unsupported) | 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, | 128 | /// Appends an attachment and text to the first text paragraph of `page`; none without one. |
| 123 | /// opens with the same password and reads back as the edited model. | 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 | #[test] | 184 | #[test] |
| 125 | fn a_protected_page_edit_is_stored_under_the_section_key() { | 185 | fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 126 | use onestore::page::{Page, PageObject, Paragraph}; | 186 | for (mut bytes, password) in fixtures() { |
| 127 | for (root, notebook) in [ | 187 | let key = Key::open(&bytes, &password).unwrap(); |
| 128 | ("native-encrypted", "encrypted-01/notebook/synthetic.one"), | 188 | let mut expected = |
| 129 | ("native-protected-boundaries", "notebook/synthetic.one"), | 189 | pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); |
| 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); | ||
| 150 | let mut edited = 0; | 190 | let mut edited = 0; |
| 151 | for (space, page) in &mut expected { | 191 | for (space, page) in &mut expected { |
| 152 | let before = page.clone(); | 192 | let Some((edit, after)) = edit(*space, page) else { |
| 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 { | ||
| 162 | continue; | 193 | continue; |
| 163 | }; | 194 | }; |
| 195 | *page = after; | ||
| 164 | edited += 1; | 196 | edited += 1; |
| 165 | let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); | 197 | let arena = Arena::default(); |
| 166 | let mut file = paragraphs[at].clone(); | 198 | let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap(); |
| 167 | file.id = onestore::page::text::new_id().unwrap(); | 199 | section.apply("Rust", &edit).unwrap(); |
| 168 | file.lists.clear(); | 200 | let transaction = section.seal().unwrap().unwrap(); |
| 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(); | ||
| 206 | let mut written = bytes.clone(); | 201 | let mut written = bytes.clone(); |
| 207 | transaction.apply(&mut written).unwrap(); | 202 | transaction.apply(&mut written).unwrap(); |
| 203 | assert_eq!(written, section.image()); | ||
| 208 | for clear in [&b" still protected"[..], b"stays sealed"] { | 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 | let revisions = |bytes: &[u8]| { | 213 | let revisions = |bytes: &[u8]| { |
| 212 | let store = Store::parse(bytes).unwrap(); | 214 | let store = Store::parse(bytes).unwrap(); |
| ... | @@ -224,13 +226,23 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { | ... | @@ -224,13 +226,23 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 224 | .map(|(before, _)| before.0) | 226 | .map(|(before, _)| before.0) |
| 225 | .collect(); | 227 | .collect(); |
| 226 | assert_eq!(grown, [*space]); | 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 | bytes = written; | 231 | bytes = written; |
| 228 | } | 232 | } |
| 229 | assert!(edited > 0); | 233 | assert!(edited > 0); |
| 230 | let stored = pages(&bytes); | 234 | let stored = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); |
| 231 | assert_eq!(stored.len(), expected.len()); | 235 | assert_eq!(objects(&stored), objects(&expected)); |
| 232 | for ((_, stored), (_, expected)) in stored.iter().zip(&expected) { | 236 | // The document reader agrees, under the password alone. |
| 233 | assert_eq!(stored.objects, expected.objects); | 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,9 +251,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { |
| 239 | #[test] | 251 | #[test] |
| 240 | fn a_native_revision_inherits_the_key_of_its_dependency() { | 252 | fn a_native_revision_inherits_the_key_of_its_dependency() { |
| 241 | let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); | 253 | let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); |
| 242 | let manifest: serde_json::Value = | 254 | let (_, password) = fixtures().remove(0); |
| 243 | serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap()) | ||
| 244 | .unwrap(); | ||
| 245 | let store = Store::parse(&bytes).unwrap(); | 255 | let store = Store::parse(&bytes).unwrap(); |
| 246 | let index = RevisionIndex::parse(&store).unwrap(); | 256 | let index = RevisionIndex::parse(&store).unwrap(); |
| 247 | assert!(index.spaces.values().any(|space| { | 257 | assert!(index.spaces.values().any(|space| { |
| ... | @@ -249,14 +259,149 @@ fn a_native_revision_inherits_the_key_of_its_dependency() { | ... | @@ -249,14 +259,149 @@ fn a_native_revision_inherits_the_key_of_its_dependency() { |
| 249 | revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) | 259 | revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) |
| 250 | }) | 260 | }) |
| 251 | })); | 261 | })); |
| 252 | let unlocked = UnlockedSection::open( | 262 | let key = Key::open(&bytes, &password).unwrap(); |
| 253 | &index, | 263 | let read = pages(&mut Section::unlock(&Arena::default(), bytes, &key).unwrap()); |
| 254 | manifest["password"].as_str().unwrap(), | 264 | assert!(format!("{:?}", read[0].1.objects).contains("Native edit after Rust.")); |
| 255 | Limits::default(), | 265 | } |
| 256 | ) | 266 | |
| 257 | .unwrap(); | 267 | /// Setting, changing and removing a password rewrites the section under new identities, |
| 258 | let document = unlocked.document().unwrap(); | 268 | /// keeping every page, version and payload; each password opens only its own image. |
| 259 | let (space, _) = document.pages().unwrap()[0]; | 269 | #[test] |
| 260 | let page = onestore::page::Page::from_space(&document, space).unwrap(); | 270 | fn a_password_is_set_changed_and_removed() { |
| 261 | assert!(format!("{:?}", page.objects).contains("Native edit after Rust.")); | 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,6 +31,8 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r |
| 31 | # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. | 31 | # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. |
| 32 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } | 32 | ureq = { version = "3.4", default-features = false, features = ["rustls"] } |
| 33 | ring = "0.17" | 33 | ring = "0.17" |
| 34 | # Clears passwords typed into the password dialogs. | ||
| 35 | zeroize = "1.9.0" | ||
| 34 | 36 | ||
| 35 | [target.'cfg(target_os = "macos")'.dependencies] | 37 | [target.'cfg(target_os = "macos")'.dependencies] |
| 36 | libc = "0.2" | 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 +5,6 @@ |
| 5 | <alias type="application/msonenote"/> | 5 | <alias type="application/msonenote"/> |
| 6 | <glob pattern="*.one"/> | 6 | <glob pattern="*.one"/> |
| 7 | <glob pattern="*.onetoc2"/> | 7 | <glob pattern="*.onetoc2"/> |
| 8 | <glob pattern="*.onepkg"/> | ||
| 8 | </mime-type> | 9 | </mime-type> |
| 9 | </mime-info> | 10 | </mime-info> |
crates/snowbound/linux/package.sh+4-26| ... | @@ -1,39 +1,17 @@ | ... | @@ -1,39 +1,17 @@ |
| 1 | #!/bin/sh | 1 | #!/bin/sh |
| 2 | # Builds target/dist/snowbound-linux-ARCH.tar.gz for each ARCH given (x86_64, aarch64; | 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 | set -eu | 4 | set -eu |
| 5 | root=$(cd "$(dirname "$0")/../../.." && pwd) | 5 | root=$(cd "$(dirname "$0")/../../.." && pwd) |
| 6 | here="$root/crates/snowbound/linux" | 6 | here="$root/crates/snowbound/linux" |
| 7 | target="$root/target" | 7 | target="$root/target" |
| 8 | tools="$target/zig" | 8 | mkdir -p "$target/dist" |
| 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" | ||
| 14 | 9 | ||
| 15 | [ $# -gt 0 ] || set -- x86_64 aarch64 | 10 | [ $# -gt 0 ] || set -- x86_64 aarch64 |
| 16 | for arch do | 11 | for arch do |
| 17 | triple=$arch-unknown-linux-gnu | 12 | triple=$arch-unknown-linux-gnu |
| 18 | variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') | 13 | CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \ |
| 19 | linker="$tools/cc-$arch" | 14 | --manifest-path "$root/Cargo.toml" --release -p snowbound |
| 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 | ||
| 37 | 15 | ||
| 38 | name=snowbound-linux-$arch | 16 | name=snowbound-linux-$arch |
| 39 | stage="$target/dist/$name" | 17 | stage="$target/dist/$name" |
crates/snowbound/src/aqua.rs+4-21| ... | @@ -17,10 +17,7 @@ use std::{ | ... | @@ -17,10 +17,7 @@ use std::{ |
| 17 | sync::atomic::{AtomicBool, Ordering}, | 17 | sync::atomic::{AtomicBool, Ordering}, |
| 18 | }; | 18 | }; |
| 19 | use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; | 19 | use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; |
| 20 | use winit::{ | 20 | use winit::window::{CursorIcon, Window}; |
| 21 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | ||
| 22 | window::{CursorIcon, Window}, | ||
| 23 | }; | ||
| 24 | 21 | ||
| 25 | static PRETEND: AtomicBool = AtomicBool::new(false); | 22 | static PRETEND: AtomicBool = AtomicBool::new(false); |
| 26 | 23 | ||
| ... | @@ -49,14 +46,8 @@ pub fn textured(window: &Window, row: f32) -> bool { | ... | @@ -49,14 +46,8 @@ pub fn textured(window: &Window, row: f32) -> bool { |
| 49 | return false; | 46 | return false; |
| 50 | } | 47 | } |
| 51 | TEXTURED.store(true, Ordering::Relaxed); | 48 | TEXTURED.store(true, Ordering::Relaxed); |
| 52 | let RawWindowHandle::AppKit(handle) = | 49 | let window = crate::platform::ns_window(window); |
| 53 | window.window_handle().expect("Live AppKit window").as_raw() | ||
| 54 | else { | ||
| 55 | unreachable!() | ||
| 56 | }; | ||
| 57 | unsafe { | 50 | unsafe { |
| 58 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | ||
| 59 | let window: Retained<AnyObject> = msg_send_id![view, window]; | ||
| 60 | let title: Retained<AnyObject> = msg_send_id![&window, title]; | 51 | let title: Retained<AnyObject> = msg_send_id![&window, title]; |
| 61 | let mask: usize = msg_send![&window, styleMask]; | 52 | let mask: usize = msg_send![&window, styleMask]; |
| 62 | // NSTexturedBackgroundWindowMask. | 53 | // NSTexturedBackgroundWindowMask. |
| ... | @@ -450,16 +441,8 @@ pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { | ... | @@ -450,16 +441,8 @@ pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { |
| 450 | } | 441 | } |
| 451 | static HIDDEN: std::sync::Once = std::sync::Once::new(); | 442 | static HIDDEN: std::sync::Once = std::sync::Once::new(); |
| 452 | HIDDEN.call_once(|| { | 443 | HIDDEN.call_once(|| { |
| 453 | let RawWindowHandle::AppKit(handle) = | 444 | let window = crate::platform::ns_window(window); |
| 454 | window.window_handle().expect("Live AppKit window").as_raw() | 445 | let _: () = unsafe { msg_send![&window, setShowsResizeIndicator: false] }; |
| 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 | } | ||
| 463 | }); | 446 | }); |
| 464 | thread_local! { | 447 | thread_local! { |
| 465 | static GRIP: draw::RasterImage = { | 448 | static GRIP: draw::RasterImage = { |
crates/snowbound/src/art.rs+2| ... | @@ -24,6 +24,7 @@ pub const ICLOUD: &[&str] = art!("icons/icloud"); | ... | @@ -24,6 +24,7 @@ pub const ICLOUD: &[&str] = art!("icons/icloud"); |
| 24 | pub const FONT_COLOR: &[&str] = art!("icons/font-color"); | 24 | pub const FONT_COLOR: &[&str] = art!("icons/font-color"); |
| 25 | pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); | 25 | pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); |
| 26 | pub const INDENT: &[&str] = art!("icons/indent"); | 26 | pub const INDENT: &[&str] = art!("icons/indent"); |
| 27 | pub const INFO: &[&str] = art!("icons/info"); | ||
| 27 | pub const ITALIC: &[&str] = art!("icons/italic"); | 28 | pub const ITALIC: &[&str] = art!("icons/italic"); |
| 28 | pub const LINK: &[&str] = art!("icons/link"); | 29 | pub const LINK: &[&str] = art!("icons/link"); |
| 29 | pub const NOTEBOOK: &[&str] = art!("icons/notebook"); | 30 | pub const NOTEBOOK: &[&str] = art!("icons/notebook"); |
| ... | @@ -46,6 +47,7 @@ pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); | ... | @@ -46,6 +47,7 @@ pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); |
| 46 | pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); | 47 | pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); |
| 47 | pub const TABLE: &[&str] = art!("icons/table"); | 48 | pub const TABLE: &[&str] = art!("icons/table"); |
| 48 | pub const UNDERLINE: &[&str] = art!("icons/underline"); | 49 | pub const UNDERLINE: &[&str] = art!("icons/underline"); |
| 50 | pub const WARNING: &[&str] = art!("icons/warning"); | ||
| 49 | pub const UNDO: &[&str] = art!("icons/undo"); | 51 | pub const UNDO: &[&str] = art!("icons/undo"); |
| 50 | pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); | 52 | pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); |
| 51 | pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); | 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,7 +26,22 @@ pub enum Id { |
| 26 | NewSubpage, | 26 | NewSubpage, |
| 27 | PageVersions, | 27 | PageVersions, |
| 28 | CopyPageLink, | 28 | CopyPageLink, |
| 29 | /// Password Protection on the section shown. | ||
| 30 | PasswordProtect, | ||
| 31 | /// Locks every password-protected section unlocked. | ||
| 32 | LockAll, | ||
| 29 | ShowNotebook, | 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 | ExportPdf, | 45 | ExportPdf, |
| 31 | Print, | 46 | Print, |
| 32 | Undo, | 47 | Undo, |
| ... | @@ -238,6 +253,18 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -238,6 +253,18 @@ pub const COMMANDS: &[Command] = &[ |
| 238 | ), | 253 | ), |
| 239 | row!(Id::PageVersions, "Page Versions", NONE, NONE), | 254 | row!(Id::PageVersions, "Page Versions", NONE, NONE), |
| 240 | row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE), | 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 | row!( | 268 | row!( |
| 242 | Id::ShowNotebook, | 269 | Id::ShowNotebook, |
| 243 | if cfg!(target_os = "macos") { | 270 | if cfg!(target_os = "macos") { |
| ... | @@ -248,6 +275,24 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -248,6 +275,24 @@ pub const COMMANDS: &[Command] = &[ |
| 248 | NONE, | 275 | NONE, |
| 249 | NONE, | 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 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), | 296 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), |
| 252 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. | 297 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. |
| 253 | row!( | 298 | row!( |
| ... | @@ -1003,7 +1048,7 @@ impl State { | ... | @@ -1003,7 +1048,7 @@ impl State { |
| 1003 | /// ⌘K, to the open palette's actions. | 1048 | /// ⌘K, to the open palette's actions. |
| 1004 | pub(crate) fn statuses(&self) -> Vec<Status> { | 1049 | pub(crate) fn statuses(&self) -> Vec<Status> { |
| 1005 | // A chord Options records reaches it past the menu bar's disabled items. | 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 | return vec![Status::default(); choices().count()]; | 1052 | return vec![Status::default(); choices().count()]; |
| 1008 | } | 1053 | } |
| 1009 | let format = self.format_state(); | 1054 | let format = self.format_state(); |
| ... | @@ -1020,6 +1065,7 @@ impl State { | ... | @@ -1020,6 +1065,7 @@ impl State { |
| 1020 | let session = self.session.as_ref(); | 1065 | let session = self.session.as_ref(); |
| 1021 | let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); | 1066 | let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); |
| 1022 | let modal = self.options.is_some() | 1067 | let modal = self.options.is_some() |
| 1068 | || self.password.is_some() | ||
| 1023 | || self.themes.is_some() | 1069 | || self.themes.is_some() |
| 1024 | || self.printing.open() | 1070 | || self.printing.open() |
| 1025 | || self.link.is_some() | 1071 | || self.link.is_some() |
| ... | @@ -1071,6 +1117,39 @@ impl State { | ... | @@ -1071,6 +1117,39 @@ impl State { |
| 1071 | | Id::Help | 1117 | | Id::Help |
| 1072 | | Id::CheckForUpdates => enabled(!modal), | 1118 | | Id::CheckForUpdates => enabled(!modal), |
| 1073 | Id::CloseNotebook => enabled(!modal && self.notebook().is_some()), | 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 | Id::ShowNotebook => enabled( | 1153 | Id::ShowNotebook => enabled( |
| 1075 | !modal | 1154 | !modal |
| 1076 | && self | 1155 | && self |
| ... | @@ -1083,13 +1162,13 @@ impl State { | ... | @@ -1083,13 +1162,13 @@ impl State { |
| 1083 | .notebook() | 1162 | .notebook() |
| 1084 | .is_some_and(|library| library.catalog().is_some()), | 1163 | .is_some_and(|library| library.catalog().is_some()), |
| 1085 | ), | 1164 | ), |
| 1086 | Id::NewPage | 1165 | // The recycle bin takes no new pages. |
| 1087 | | Id::NewSubpage | 1166 | Id::NewPage | Id::NewSubpage => { |
| 1088 | | Id::CopyPageLink | 1167 | enabled(!modal && session.is_some() && !self.in_recycle_bin()) |
| 1089 | | Id::Find | 1168 | } |
| 1090 | | Id::Search | 1169 | Id::CopyPageLink | Id::Find | Id::Search | Id::ExportPdf | Id::Print => { |
| 1091 | | Id::ExportPdf | 1170 | enabled(!modal && session.is_some()) |
| 1092 | | Id::Print => enabled(!modal && session.is_some()), | 1171 | } |
| 1093 | Id::PageVersions => session.filter(|_| !modal).map_or( | 1172 | Id::PageVersions => session.filter(|_| !modal).map_or( |
| 1094 | Status { | 1173 | Status { |
| 1095 | enabled: false, | 1174 | enabled: false, |
| ... | @@ -1338,6 +1417,40 @@ impl State { | ... | @@ -1338,6 +1417,40 @@ impl State { |
| 1338 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); | 1417 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); |
| 1339 | return Ok(()); | 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 | Id::ExportPdf | Id::Print => { | 1454 | Id::ExportPdf | Id::Print => { |
| 1342 | self.open_print(id == Id::ExportPdf); | 1455 | self.open_print(id == Id::ExportPdf); |
| 1343 | return Ok(()); | 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,7 +64,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { |
| 64 | &theme, | 64 | &theme, |
| 65 | &section, | 65 | &section, |
| 66 | session, | 66 | session, |
| 67 | &HashSet::new(), | 67 | [&HashSet::new(); 2], |
| 68 | (0.0, false), | 68 | (0.0, false), |
| 69 | None, | 69 | None, |
| 70 | None, | 70 | None, |
crates/snowbound/src/desktop_linux.rs+1-1| ... | @@ -36,7 +36,7 @@ use winit::{ | ... | @@ -36,7 +36,7 @@ use winit::{ |
| 36 | /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. | 36 | /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. |
| 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; | 37 | pub const APP_ID: &str = "net.paperclover.snowbound"; |
| 38 | const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); | 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 | const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); | 40 | const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); |
| 41 | /// 512 pixels square; the smaller sizes are scaled from it. | 41 | /// 512 pixels square; the smaller sizes are scaled from it. |
| 42 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); | 42 | const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); |
crates/snowbound/src/history.rs+34-52| ... | @@ -23,9 +23,12 @@ impl Session { | ... | @@ -23,9 +23,12 @@ impl Session { |
| 23 | .map_or(&[], |(_, versions)| versions) | 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 | pub(crate) fn read_only(&self) -> bool { | 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 | /// Version `version` of page `space` as the editor shows it: read-only, what it changed | 34 | /// Version `version` of page `space` as the editor shows it: read-only, what it changed |
| ... | @@ -166,6 +169,7 @@ impl State { | ... | @@ -166,6 +169,7 @@ impl State { |
| 166 | .section | 169 | .section |
| 167 | .restore_version(page, version, &self.author)?; | 170 | .restore_version(page, version, &self.author)?; |
| 168 | session.refresh_history()?; | 171 | session.refresh_history()?; |
| 172 | self.edited(vec![page]); | ||
| 169 | self.commands.push(Command::OpenPage(page)); | 173 | self.commands.push(Command::OpenPage(page)); |
| 170 | Ok(()) | 174 | Ok(()) |
| 171 | } | 175 | } |
| ... | @@ -207,19 +211,7 @@ impl State { | ... | @@ -207,19 +211,7 @@ impl State { |
| 207 | ) { | 211 | ) { |
| 208 | return Ok(()); | 212 | return Ok(()); |
| 209 | } | 213 | } |
| 210 | let all: Vec<(ExGuid, Vec<ExGuid>)> = session | 214 | clear(&session.section)?; |
| 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 | } | ||
| 223 | session.refresh_history()?; | 215 | session.refresh_history()?; |
| 224 | if session.version.is_some() { | 216 | if session.version.is_some() { |
| 225 | self.commands.push(Command::OpenPage(session.space)); | 217 | self.commands.push(Command::OpenPage(session.space)); |
| ... | @@ -242,22 +234,7 @@ impl State { | ... | @@ -242,22 +234,7 @@ impl State { |
| 242 | let cleared = library | 234 | let cleared = library |
| 243 | .open(&path, notify(proxy.clone())) | 235 | .open(&path, notify(proxy.clone())) |
| 244 | .and_then(|section| { | 236 | .and_then(|section| { |
| 245 | let all: Vec<(ExGuid, Vec<ExGuid>)> = section | 237 | clear(&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 | } | ||
| 261 | Ok(section.close()?) | 238 | Ok(section.close()?) |
| 262 | }); | 239 | }); |
| 263 | if let Err(error) = cleared { | 240 | if let Err(error) = cleared { |
| ... | @@ -269,31 +246,36 @@ impl State { | ... | @@ -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 | /// The readable sections at catalog path `folder` and in the groups inside it, leaving out | 265 | /// The readable sections at catalog path `folder` and in the groups inside it, leaving out |
| 273 | /// the recycle bin. | 266 | /// the recycle bin. |
| 274 | fn sections(library: &Library, folder: &str) -> Vec<String> { | 267 | fn sections(library: &Library, folder: &str) -> Vec<String> { |
| 275 | let Some(catalog) = library.catalog() else { | 268 | let Some(catalog) = library.catalog() else { |
| 276 | return vec![library.location.clone()]; | 269 | return vec![library.location.clone()]; |
| 277 | }; | 270 | }; |
| 278 | let mut pending = vec![catalog]; | 271 | let root = library::folders(catalog, |_| true) |
| 279 | let mut root = None; | 272 | .into_iter() |
| 280 | while let Some(candidate) = pending.pop() { | 273 | .find(|candidate| candidate.path == folder); |
| 281 | if candidate.path == folder { | 274 | root.map_or_else(Vec::new, |root| { |
| 282 | root = Some(candidate); | 275 | library::folders(root, |group| !library::recycle_bin(&group.path)) |
| 283 | break; | 276 | }) |
| 284 | } | 277 | .into_iter() |
| 285 | pending.extend(&candidate.groups); | 278 | .flat_map(|folder| library.tabs(&folder.path)) |
| 286 | } | 279 | .map(|tab| tab.path) |
| 287 | let mut pending: Vec<_> = root.into_iter().collect(); | 280 | .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 | ||
| 299 | } | 281 | } |
crates/snowbound/src/icloud_linux.rs+5-1| ... | @@ -25,9 +25,13 @@ pub fn section( | ... | @@ -25,9 +25,13 @@ pub fn section( |
| 25 | notebook: &Notebook, | 25 | notebook: &Notebook, |
| 26 | _: &Path, | 26 | _: &Path, |
| 27 | path: &str, | 27 | path: &str, |
| 28 | key: Option<&onestore::protected::Key>, | ||
| 28 | notify: impl Fn() + Send + 'static, | 29 | notify: impl Fn() + Send + 'static, |
| 29 | ) -> Result<Section, notebook::Error> { | 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 | pub fn lone_section( | 37 | pub fn lone_section( |
crates/snowbound/src/icloud_macos.rs+7-2| ... | @@ -363,12 +363,17 @@ pub fn section( | ... | @@ -363,12 +363,17 @@ pub fn section( |
| 363 | notebook: &Notebook, | 363 | notebook: &Notebook, |
| 364 | root: &Path, | 364 | root: &Path, |
| 365 | path: &str, | 365 | path: &str, |
| 366 | key: Option<&onestore::protected::Key>, | ||
| 366 | notify: impl Fn() + Send + 'static, | 367 | notify: impl Fn() + Send + 'static, |
| 367 | ) -> Result<Section, notebook::Error> { | 368 | ) -> Result<Section, notebook::Error> { |
| 368 | if !notebook.replica_path(path)?.exists() { | 369 | if !notebook.replica_path(path)?.exists() { |
| 369 | fetch(&root.join(path))?; | 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 | section.set_pause(PAUSE); | 377 | section.set_pause(PAUSE); |
| 373 | Ok(section) | 378 | Ok(section) |
| 374 | } | 379 | } |
| ... | @@ -576,7 +581,7 @@ mod tests { | ... | @@ -576,7 +581,7 @@ mod tests { |
| 576 | let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); | 581 | let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); |
| 577 | assert!(ubiquitous(&root)); | 582 | assert!(ubiquitous(&root)); |
| 578 | let path = notebook.catalog().sections[0].path.clone(); | 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 | let (space, ..) = section.pages().unwrap()[0].clone(); | 585 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 581 | let title = section | 586 | let title = section |
| 582 | .page(space) | 587 | .page(space) |
crates/snowbound/src/keys.rs+1-8| ... | @@ -78,7 +78,7 @@ fn label(chord: Chord) -> String { | ... | @@ -78,7 +78,7 @@ fn label(chord: Chord) -> String { |
| 78 | fn title(id: Command) -> String { | 78 | fn title(id: Command) -> String { |
| 79 | match id { | 79 | match id { |
| 80 | Command::Tag(place) => format!("Tag {}", place + 1), | 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,13 +430,6 @@ impl crate::State { |
| 430 | taken | 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 | /// Puts `keymap` in use, and in the menu bar where it changes. | 433 | /// Puts `keymap` in use, and in the menu bar where it changes. |
| 441 | pub(crate) fn install_keymap(&self, keymap: Keymap) { | 434 | pub(crate) fn install_keymap(&self, keymap: Keymap) { |
| 442 | if keymap.install() { | 435 | if keymap.install() { |
crates/snowbound/src/library.rs+214-136| ... | @@ -5,6 +5,7 @@ use notebook::discover::{Folder, SectionState}; | ... | @@ -5,6 +5,7 @@ use notebook::discover::{Folder, SectionState}; |
| 5 | use notebook::session::{Background, Notebook, Section}; | 5 | use notebook::session::{Background, Notebook, Section}; |
| 6 | use notebook::sidecar::themes::Themes; | 6 | use notebook::sidecar::themes::Themes; |
| 7 | use notebook::smb::{Client, Credentials}; | 7 | use notebook::smb::{Client, Credentials}; |
| 8 | use onestore::protected::Key; | ||
| 8 | use std::{ | 9 | use std::{ |
| 9 | error::Error, | 10 | error::Error, |
| 10 | io, | 11 | io, |
| ... | @@ -151,14 +152,14 @@ impl Mount { | ... | @@ -151,14 +152,14 @@ impl Mount { |
| 151 | let mut url = String::from("smb://"); | 152 | let mut url = String::from("smb://"); |
| 152 | if let Some(user) = &self.user { | 153 | if let Some(user) = &self.user { |
| 153 | if !self.domain.is_empty() { | 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 | url += &self.server; | 159 | url += &self.server; |
| 159 | for part in [&self.share, &self.root] { | 160 | for part in [&self.share, &self.root] { |
| 160 | if !part.is_empty() { | 161 | if !part.is_empty() { |
| 161 | url += &format!("/{}", encode(part)); | 162 | url += &format!("/{}", encode(part.as_bytes())); |
| 162 | } | 163 | } |
| 163 | } | 164 | } |
| 164 | url | 165 | url |
| ... | @@ -232,10 +233,11 @@ fn decode(text: &str) -> String { | ... | @@ -232,10 +233,11 @@ fn decode(text: &str) -> String { |
| 232 | String::from_utf8_lossy(&decoded).into_owned() | 233 | String::from_utf8_lossy(&decoded).into_owned() |
| 233 | } | 234 | } |
| 234 | 235 | ||
| 235 | /// `text` escaped for an address, `/` kept. | 236 | /// `bytes` escaped for an address, outside RFC 3986's unreserved set and `/`. |
| 236 | fn encode(text: &str) -> String { | 237 | pub(crate) fn encode(bytes: &[u8]) -> String { |
| 237 | text.bytes() | 238 | bytes |
| 238 | .map(|byte| match byte { | 239 | .iter() |
| 240 | .map(|&byte| match byte { | ||
| 239 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { | 241 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { |
| 240 | char::from(byte).to_string() | 242 | char::from(byte).to_string() |
| 241 | } | 243 | } |
| ... | @@ -318,9 +320,34 @@ pub struct Library { | ... | @@ -318,9 +320,34 @@ pub struct Library { |
| 318 | kept: Mutex<crate::prefetch::Recent<String, Section>>, | 320 | kept: Mutex<crate::prefetch::Recent<String, Section>>, |
| 319 | /// The notebook's style themes, and when they were read. | 321 | /// The notebook's style themes, and when they were read. |
| 320 | themes: Mutex<Option<(Instant, Arc<Themes>)>>, | 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 | impl Library { | 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 | /// The notebook in the folder at `location`, or why it cannot be read. A folder on a | 351 | /// The notebook in the folder at `location`, or why it cannot be read. A folder on a |
| 325 | /// mounted SMB share opens through Snowbound's own SMB client, signed in with the | 352 | /// mounted SMB share opens through Snowbound's own SMB client, signed in with the |
| 326 | /// account the system keeps for the mount, as OneNote's own client coordinates with | 353 | /// account the system keeps for the mount, as OneNote's own client coordinates with |
| ... | @@ -333,18 +360,12 @@ impl Library { | ... | @@ -333,18 +360,12 @@ impl Library { |
| 333 | }; | 360 | }; |
| 334 | return login | 361 | return login |
| 335 | .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) | 362 | .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) |
| 336 | .unwrap_or_else(|reason| Self { | 363 | .unwrap_or_else(|reason| { |
| 337 | location: location.to_owned(), | 364 | let name = display_name(cache, location).unwrap_or_else(|| mount.name()); |
| 338 | name: display_name(cache, location).unwrap_or_else(|| mount.name()), | 365 | Self { |
| 339 | notebook: Err(reason), | 366 | notebook: Err(reason), |
| 340 | server: None, | 367 | ..Self::new(location, name, cache) |
| 341 | notice: None, | 368 | } |
| 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(), | ||
| 348 | }); | 369 | }); |
| 349 | } | 370 | } |
| 350 | let mut notice = None; | 371 | let mut notice = None; |
| ... | @@ -364,20 +385,16 @@ impl Library { | ... | @@ -364,20 +385,16 @@ impl Library { |
| 364 | Ok(notebook) => local_background(notebook, location), | 385 | Ok(notebook) => local_background(notebook, location), |
| 365 | Err(_) => (None, None), | 386 | Err(_) => (None, None), |
| 366 | }; | 387 | }; |
| 388 | let name = display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))); | ||
| 367 | Self { | 389 | Self { |
| 368 | location: location.to_owned(), | ||
| 369 | name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))), | ||
| 370 | background, | 390 | background, |
| 371 | watch, | 391 | watch, |
| 372 | tag_art: Mutex::new(Arc::new( | 392 | tag_art: Mutex::new(Arc::new( |
| 373 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), | 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 | notebook: notebook.map(Some).map_err(|error| error.to_string()), | 395 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 378 | server: None, | ||
| 379 | notice, | 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,21 +422,17 @@ impl Library { |
| 405 | .map_err(|error| error.to_string())?; | 422 | .map_err(|error| error.to_string())?; |
| 406 | background.set_offline(offline()); | 423 | background.set_offline(offline()); |
| 407 | background.watch(notebook.replicas()); | 424 | background.watch(notebook.replicas()); |
| 408 | Ok(Self { | 425 | let name = |
| 409 | location: location.to_owned(), | 426 | display_name(cache, location).unwrap_or_else(|| match server_address(location) { |
| 410 | name: display_name(cache, location).unwrap_or_else(|| match server_address(location) { | ||
| 411 | Some(_) => server.mount.name(), | 427 | Some(_) => server.mount.name(), |
| 412 | None => file_name(Path::new(location)), | 428 | None => file_name(Path::new(location)), |
| 413 | }), | 429 | }); |
| 430 | Ok(Self { | ||
| 414 | tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))), | 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 | notebook: Ok(Some(notebook)), | 432 | notebook: Ok(Some(notebook)), |
| 418 | server: Some(server), | 433 | server: Some(server), |
| 419 | notice: None, | ||
| 420 | cache: cache.to_owned(), | ||
| 421 | background: Some(Arc::new(background)), | 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,18 +589,16 @@ impl Library { |
| 576 | } | 589 | } |
| 577 | _ => self.watch.clone(), | 590 | _ => self.watch.clone(), |
| 578 | }; | 591 | }; |
| 592 | let name = display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()); | ||
| 579 | Self { | 593 | Self { |
| 580 | location: self.location.clone(), | ||
| 581 | name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()), | ||
| 582 | notebook: Ok(Some(notebook)), | 594 | notebook: Ok(Some(notebook)), |
| 583 | server: self.server.clone(), | 595 | server: self.server.clone(), |
| 584 | notice: self.notice.clone(), | 596 | notice: self.notice.clone(), |
| 585 | cache: self.cache.clone(), | ||
| 586 | background: self.background.clone(), | 597 | background: self.background.clone(), |
| 587 | watch, | 598 | watch, |
| 588 | tag_art: Mutex::new(self.tag_art()), | 599 | tag_art: Mutex::new(self.tag_art()), |
| 589 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | 600 | keys: Arc::clone(&self.keys), |
| 590 | themes: Default::default(), | 601 | ..Self::new(&self.location, name, &self.cache) |
| 591 | } | 602 | } |
| 592 | } | 603 | } |
| 593 | 604 | ||
| ... | @@ -595,35 +606,20 @@ impl Library { | ... | @@ -595,35 +606,20 @@ impl Library { |
| 595 | pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { | 606 | pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { |
| 596 | let (background, watch) = local_background(&mut notebook, location); | 607 | let (background, watch) = local_background(&mut notebook, location); |
| 597 | Self { | 608 | Self { |
| 598 | location: location.to_owned(), | ||
| 599 | name: file_name(Path::new(location)), | ||
| 600 | background, | 609 | background, |
| 601 | watch, | 610 | watch, |
| 602 | tag_art: Default::default(), | ||
| 603 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | ||
| 604 | themes: Default::default(), | ||
| 605 | notebook: Ok(Some(notebook)), | 611 | notebook: Ok(Some(notebook)), |
| 606 | server: None, | 612 | ..Self::new(location, file_name(Path::new(location)), cache) |
| 607 | notice: None, | ||
| 608 | cache: cache.to_owned(), | ||
| 609 | } | 613 | } |
| 610 | } | 614 | } |
| 611 | 615 | ||
| 612 | /// The section file at `file` as a notebook of one tab. | 616 | /// The section file at `file` as a notebook of one tab. |
| 613 | pub fn section(file: &Path, cache: &Path) -> Self { | 617 | pub fn section(file: &Path, cache: &Path) -> Self { |
| 614 | Self { | 618 | Self::new( |
| 615 | location: file.to_string_lossy().into_owned(), | 619 | &file.to_string_lossy(), |
| 616 | name: file_name(file.parent().unwrap_or(file)), | 620 | file_name(file.parent().unwrap_or(file)), |
| 617 | notebook: Ok(None), | 621 | cache, |
| 618 | server: None, | 622 | ) |
| 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 | } | ||
| 627 | } | 623 | } |
| 628 | 624 | ||
| 629 | /// Opens the section at catalog `path`; on a share, through a replica named by its | 625 | /// Opens the section at catalog `path`; on a share, through a replica named by its |
| ... | @@ -654,6 +650,11 @@ impl Library { | ... | @@ -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 | let section = loop { | 658 | let section = loop { |
| 658 | if let Some(kept) = self | 659 | if let Some(kept) = self |
| 659 | .kept | 660 | .kept |
| ... | @@ -671,7 +672,7 @@ impl Library { | ... | @@ -671,7 +672,7 @@ impl Library { |
| 671 | }; | 672 | }; |
| 672 | let cache = notebook.replica_path(path)?; | 673 | let cache = notebook.replica_path(path)?; |
| 673 | std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; | 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 | Ok(server.connect()?.read_storage(&file, LIMIT)?) | 676 | Ok(server.connect()?.read_storage(&file, LIMIT)?) |
| 676 | }); | 677 | }); |
| 677 | replica.and_then(|replica| { | 678 | replica.and_then(|replica| { |
| ... | @@ -681,9 +682,13 @@ impl Library { | ... | @@ -681,9 +682,13 @@ impl Library { |
| 681 | }) | 682 | }) |
| 682 | } | 683 | } |
| 683 | (Ok(Some(notebook)), None) if self.in_icloud() => { | 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 | (Ok(None), _) if self.in_icloud() => { | 692 | (Ok(None), _) if self.in_icloud() => { |
| 688 | crate::icloud::lone_section(Path::new(path), &self.cache, notifier()) | 693 | crate::icloud::lone_section(Path::new(path), &self.cache, notifier()) |
| 689 | } | 694 | } |
| ... | @@ -835,16 +840,10 @@ impl Library { | ... | @@ -835,16 +840,10 @@ impl Library { |
| 835 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. | 840 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. |
| 836 | pub fn tabs(&self, folder: &str) -> Vec<Tab> { | 841 | pub fn tabs(&self, folder: &str) -> Vec<Tab> { |
| 837 | match &self.notebook { | 842 | match &self.notebook { |
| 838 | Ok(Some(notebook)) => { | 843 | Ok(Some(notebook)) => folders(notebook.catalog(), |_| true) |
| 839 | let mut folders = vec![notebook.catalog()]; | 844 | .into_iter() |
| 840 | while let Some(candidate) = folders.pop() { | 845 | .find(|candidate| candidate.path == folder) |
| 841 | if candidate.path == folder { | 846 | .map_or_else(Vec::new, tabs), |
| 842 | return tabs(candidate); | ||
| 843 | } | ||
| 844 | folders.extend(&candidate.groups); | ||
| 845 | } | ||
| 846 | Vec::new() | ||
| 847 | } | ||
| 848 | Ok(None) => vec![Tab { | 847 | Ok(None) => vec![Tab { |
| 849 | path: self.location.clone(), | 848 | path: self.location.clone(), |
| 850 | name: section_name(&self.location, &None), | 849 | name: section_name(&self.location, &None), |
| ... | @@ -856,38 +855,23 @@ impl Library { | ... | @@ -856,38 +855,23 @@ impl Library { |
| 856 | 855 | ||
| 857 | /// The file identity of the section at catalog `path`. | 856 | /// The file identity of the section at catalog `path`. |
| 858 | pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { | 857 | pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { |
| 859 | let Ok(Some(notebook)) = &self.notebook else { | 858 | folders(self.catalog()?, |_| true) |
| 860 | return None; | 859 | .into_iter() |
| 861 | }; | 860 | .flat_map(|folder| &folder.sections) |
| 862 | let mut folders = vec![notebook.catalog()]; | 861 | .find(|section| section.path == path) |
| 863 | while let Some(folder) = folders.pop() { | 862 | .map(|section| section.file_id) |
| 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 | ||
| 870 | } | 863 | } |
| 871 | 864 | ||
| 872 | /// The first readable section, searching groups after sections, as OneNote opens a | 865 | /// The first readable section, searching groups after sections, as OneNote opens a |
| 873 | /// notebook. | 866 | /// notebook. |
| 874 | pub fn first_section(&self) -> Option<String> { | 867 | pub fn first_section(&self) -> Option<String> { |
| 875 | let Ok(Some(notebook)) = &self.notebook else { | 868 | let Some(catalog) = self.catalog() else { |
| 876 | return self.tabs("").pop().map(|tab| tab.path); | 869 | return self.tabs("").pop().map(|tab| tab.path); |
| 877 | }; | 870 | }; |
| 878 | let mut folders = std::collections::VecDeque::from([notebook.catalog()]); | 871 | folders(catalog, |group| !recycle_bin(&group.path)) |
| 879 | while let Some(folder) = folders.pop_front() { | 872 | .into_iter() |
| 880 | if let Some(tab) = tabs(folder).into_iter().next() { | 873 | .find_map(|folder| tabs(folder).into_iter().next()) |
| 881 | return Some(tab.path); | 874 | .map(|tab| tab.path) |
| 882 | } | ||
| 883 | folders.extend( | ||
| 884 | folder | ||
| 885 | .groups | ||
| 886 | .iter() | ||
| 887 | .filter(|group| !recycle_bin(&group.path)), | ||
| 888 | ); | ||
| 889 | } | ||
| 890 | None | ||
| 891 | } | 875 | } |
| 892 | 876 | ||
| 893 | /// Whether the notebook lists a section at catalog path `path`. | 877 | /// Whether the notebook lists a section at catalog path `path`. |
| ... | @@ -895,14 +879,9 @@ impl Library { | ... | @@ -895,14 +879,9 @@ impl Library { |
| 895 | let Some(catalog) = self.catalog() else { | 879 | let Some(catalog) = self.catalog() else { |
| 896 | return self.location == path; | 880 | return self.location == path; |
| 897 | }; | 881 | }; |
| 898 | let mut folders = vec![catalog]; | 882 | folders(catalog, |_| true) |
| 899 | while let Some(folder) = folders.pop() { | 883 | .iter() |
| 900 | if folder.sections.iter().any(|section| section.path == path) { | 884 | .any(|folder| folder.sections.iter().any(|section| section.path == path)) |
| 901 | return true; | ||
| 902 | } | ||
| 903 | folders.extend(&folder.groups); | ||
| 904 | } | ||
| 905 | false | ||
| 906 | } | 885 | } |
| 907 | 886 | ||
| 908 | /// The notebook's colour, COLORREF, as its table of contents holds it. | 887 | /// The notebook's colour, COLORREF, as its table of contents holds it. |
| ... | @@ -913,21 +892,12 @@ impl Library { | ... | @@ -913,21 +892,12 @@ impl Library { |
| 913 | /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps | 892 | /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps |
| 914 | /// them elsewhere. | 893 | /// them elsewhere. |
| 915 | pub fn downloading(&self) -> bool { | 894 | pub fn downloading(&self) -> bool { |
| 916 | let Some(catalog) = self.catalog() else { | 895 | self.catalog().is_some_and(|catalog| { |
| 917 | return false; | 896 | folders(catalog, |_| true).iter().any(|folder| { |
| 918 | }; | 897 | (folder.unavailable.iter()) |
| 919 | let mut folders = vec![catalog]; | 898 | .any(|entry| entry.reason == notebook::discover::Reason::Evicted) |
| 920 | while let Some(folder) = folders.pop() { | 899 | }) |
| 921 | if folder | 900 | }) |
| 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 | ||
| 931 | } | 901 | } |
| 932 | 902 | ||
| 933 | /// The notebook's folders of sections, for the sidebar. | 903 | /// The notebook's folders of sections, for the sidebar. |
| ... | @@ -937,6 +907,89 @@ impl Library { | ... | @@ -937,6 +907,89 @@ impl Library { |
| 937 | _ => None, | 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 | /// The share folder a notebook opened from its server's address lives in, as its location | 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,7 +1001,6 @@ pub fn server_address(location: &str) -> Option<Mount> { |
| 948 | .flatten() | 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 | /// `Library::key` for the notebook at `location`, open or not. | 1004 | /// `Library::key` for the notebook at `location`, open or not. |
| 953 | pub fn key(location: &str, path: &str) -> String { | 1005 | pub fn key(location: &str, path: &str) -> String { |
| 954 | format!("{location}\n{path}") | 1006 | format!("{location}\n{path}") |
| ... | @@ -971,13 +1023,28 @@ fn display_name(cache: &Path, location: &str) -> Option<String> { | ... | @@ -971,13 +1023,28 @@ fn display_name(cache: &Path, location: &str) -> Option<String> { |
| 971 | read_display_names(&display_names(cache)).remove(location) | 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 | pub fn recycle_bin(path: &str) -> bool { | 1044 | pub fn recycle_bin(path: &str) -> bool { |
| 975 | path.rsplit('/').next() == Some("OneNote_RecycleBin") | 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 | /// The art `notebook` maps its tags to, each picture read once a run. | 1048 | /// The art `notebook` maps its tags to, each picture read once a run. |
| 982 | fn read_tag_art(notebook: &Notebook) -> TagArt { | 1049 | fn read_tag_art(notebook: &Notebook) -> TagArt { |
| 983 | let mut read = TagArt::default(); | 1050 | let mut read = TagArt::default(); |
| ... | @@ -999,6 +1066,9 @@ fn read_tag_art(notebook: &Notebook) -> TagArt { | ... | @@ -999,6 +1066,9 @@ fn read_tag_art(notebook: &Notebook) -> TagArt { |
| 999 | read | 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 | fn local_background( | 1072 | fn local_background( |
| 1003 | notebook: &mut Notebook, | 1073 | notebook: &mut Notebook, |
| 1004 | location: &str, | 1074 | location: &str, |
| ... | @@ -1061,6 +1131,10 @@ pub fn file_name(path: &Path) -> String { | ... | @@ -1061,6 +1131,10 @@ pub fn file_name(path: &Path) -> String { |
| 1061 | /// A section's name: its display name, or its file's. | 1131 | /// A section's name: its display name, or its file's. |
| 1062 | pub fn section_name(path: &str, name: &Option<String>) -> String { | 1132 | pub fn section_name(path: &str, name: &Option<String>) -> String { |
| 1063 | name.clone().unwrap_or_else(|| { | 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 | Path::new(path) | 1138 | Path::new(path) |
| 1065 | .file_stem() | 1139 | .file_stem() |
| 1066 | .map(|stem| stem.to_string_lossy().into_owned()) | 1140 | .map(|stem| stem.to_string_lossy().into_owned()) |
| ... | @@ -1079,6 +1153,12 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { | ... | @@ -1079,6 +1153,12 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { |
| 1079 | path: section.path.clone(), | 1153 | path: section.path.clone(), |
| 1080 | color: *color, | 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 | _ => None, | 1162 | _ => None, |
| 1083 | }) | 1163 | }) |
| 1084 | .collect() | 1164 | .collect() |
| ... | @@ -1094,6 +1174,8 @@ pub enum Located { | ... | @@ -1094,6 +1174,8 @@ pub enum Located { |
| 1094 | }, | 1174 | }, |
| 1095 | /// A section file outside any notebook. | 1175 | /// A section file outside any notebook. |
| 1096 | Section(PathBuf), | 1176 | Section(PathBuf), |
| 1177 | /// A OneNote package, which unpacks into a notebook. | ||
| 1178 | Package(PathBuf), | ||
| 1097 | Nothing, | 1179 | Nothing, |
| 1098 | } | 1180 | } |
| 1099 | 1181 | ||
| ... | @@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool { | ... | @@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool { |
| 1111 | 1193 | ||
| 1112 | /// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a | 1194 | /// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a |
| 1113 | /// section opens in the notebook the folders above it with tables of contents make up, | 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 | pub fn locate(path: &Path) -> Located { | 1197 | pub fn locate(path: &Path) -> Located { |
| 1116 | let extension = path | 1198 | let extension = path |
| 1117 | .extension() | 1199 | .extension() |
| ... | @@ -1121,6 +1203,7 @@ pub fn locate(path: &Path) -> Located { | ... | @@ -1121,6 +1203,7 @@ pub fn locate(path: &Path) -> Located { |
| 1121 | root: path.to_owned(), | 1203 | root: path.to_owned(), |
| 1122 | section: None, | 1204 | section: None, |
| 1123 | }, | 1205 | }, |
| 1206 | Some("onepkg") => Located::Package(path.to_owned()), | ||
| 1124 | Some("onetoc2") => match path.parent() { | 1207 | Some("onetoc2") => match path.parent() { |
| 1125 | Some(root) => Located::Notebook { | 1208 | Some(root) => Located::Notebook { |
| 1126 | root: root.to_owned(), | 1209 | root: root.to_owned(), |
| ... | @@ -1327,9 +1410,6 @@ mod tests { | ... | @@ -1327,9 +1410,6 @@ mod tests { |
| 1327 | #[test] | 1410 | #[test] |
| 1328 | fn share_relative_sections_show_under_their_mount() { | 1411 | fn share_relative_sections_show_under_their_mount() { |
| 1329 | let library = |root: &str| Library { | 1412 | let library = |root: &str| Library { |
| 1330 | location: "/Volumes/agent/lab".into(), | ||
| 1331 | name: "lab".into(), | ||
| 1332 | notebook: Ok(None), | ||
| 1333 | server: Some(Arc::new(Server { | 1413 | server: Some(Arc::new(Server { |
| 1334 | mount: Mount { | 1414 | mount: Mount { |
| 1335 | server: "nas".into(), | 1415 | server: "nas".into(), |
| ... | @@ -1344,13 +1424,7 @@ mod tests { | ... | @@ -1344,13 +1424,7 @@ mod tests { |
| 1344 | domain: String::new(), | 1424 | domain: String::new(), |
| 1345 | }, | 1425 | }, |
| 1346 | })), | 1426 | })), |
| 1347 | notice: None, | 1427 | ..Library::new("/Volumes/agent/lab", "lab".into(), Path::new("")) |
| 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(), | ||
| 1354 | }; | 1428 | }; |
| 1355 | let shown = |root, file| library(root).local(Path::new(file)); | 1429 | let shown = |root, file| library(root).local(Path::new(file)); |
| 1356 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); | 1430 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
| ... | @@ -1447,6 +1521,10 @@ mod tests { | ... | @@ -1447,6 +1521,10 @@ mod tests { |
| 1447 | locate(&root.join("Loose.one")), | 1521 | locate(&root.join("Loose.one")), |
| 1448 | Located::Section(root.join("Loose.one")) | 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 | assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); | 1528 | assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); |
| 1451 | std::fs::remove_dir_all(&root).unwrap(); | 1529 | std::fs::remove_dir_all(&root).unwrap(); |
| 1452 | } | 1530 | } |
crates/snowbound/src/link.rs+1-16| ... | @@ -226,22 +226,7 @@ impl State { | ... | @@ -226,22 +226,7 @@ impl State { |
| 226 | } | 226 | } |
| 227 | } | 227 | } |
| 228 | ui.close(); | 228 | ui.close(); |
| 229 | ui.open( | 229 | crate::buttons(ui); |
| 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 | ); | ||
| 245 | let ok = ui::button(ui, "ok", "OK").clicked || entered; | 230 | let ok = ui::button(ui, "ok", "OK").clicked || entered; |
| 246 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 231 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 247 | ui.close(); | 232 | ui.close(); |
crates/snowbound/src/linux.rs+39-43| ... | @@ -72,12 +72,12 @@ pub fn corner_radius(window: &Window) -> f32 { | ... | @@ -72,12 +72,12 @@ pub fn corner_radius(window: &Window) -> f32 { |
| 72 | (units * scale).round() / scale | 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 | /// Winit's theme already holds the window to the app's appearance. | 75 | /// Winit's theme already holds the window to the app's appearance. |
| 79 | pub fn follow_appearance(_: &Window, _: Theme) {} | 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 | pub fn cuts_corners() -> bool { | 81 | pub fn cuts_corners() -> bool { |
| 82 | sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) | 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,6 +617,11 @@ pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option<ResizeDire |
| 617 | (!window.is_maximized() && window.is_resizable()).then_some(direction) | 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 | pub fn zoom(window: &Window) { | 625 | pub fn zoom(window: &Window) { |
| 621 | window.set_maximized(!window.is_maximized()); | 626 | window.set_maximized(!window.is_maximized()); |
| 622 | } | 627 | } |
| ... | @@ -773,18 +778,19 @@ pub fn show_file(file: &std::path::Path) { | ... | @@ -773,18 +778,19 @@ pub fn show_file(file: &std::path::Path) { |
| 773 | 778 | ||
| 774 | /// The `file://` URI of absolute `path`, its bytes outside RFC 3986's unreserved set and `/` | 779 | /// The `file://` URI of absolute `path`, its bytes outside RFC 3986's unreserved set and `/` |
| 775 | /// percent-encoded. | 780 | /// percent-encoded. |
| 776 | fn file_uri(path: &std::path::Path) -> String { | 781 | pub(crate) fn file_uri(path: &std::path::Path) -> String { |
| 777 | use std::os::unix::ffi::OsStrExt; | 782 | use std::os::unix::ffi::OsStrExt; |
| 778 | let mut uri = "file://".to_owned(); | 783 | format!( |
| 779 | for &byte in path.as_os_str().as_bytes() { | 784 | "file://{}", |
| 780 | match byte { | 785 | crate::library::encode(path.as_os_str().as_bytes()) |
| 781 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { | 786 | ) |
| 782 | uri.push(byte as char) | 787 | } |
| 783 | } | 788 | |
| 784 | _ => uri.push_str(&format!("%{byte:02X}")), | 789 | /// The function `name` in `library`, of C signature `F`. |
| 785 | } | 790 | pub(crate) unsafe fn symbol<F: Copy>(library: *mut libc::c_void, name: &CStr) -> Option<F> { |
| 786 | } | 791 | let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; |
| 787 | uri | 792 | (!symbol.is_null()) |
| 793 | .then(|| unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) }) | ||
| 788 | } | 794 | } |
| 789 | 795 | ||
| 790 | pub fn cache_dir() -> Option<PathBuf> { | 796 | pub fn cache_dir() -> Option<PathBuf> { |
| ... | @@ -1124,6 +1130,17 @@ fn dialog<const Z: usize, const K: usize>( | ... | @@ -1124,6 +1130,17 @@ fn dialog<const Z: usize, const K: usize>( |
| 1124 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) | 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 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. | 1144 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1128 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | 1145 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1129 | let patterns = match types { | 1146 | let patterns = match types { |
| ... | @@ -1134,21 +1151,14 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | ... | @@ -1134,21 +1151,14 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1134 | .collect::<Vec<_>>() | 1151 | .collect::<Vec<_>>() |
| 1135 | .join(" "), | 1152 | .join(" "), |
| 1136 | }; | 1153 | }; |
| 1137 | let asked = dialog( | 1154 | picked(dialog( |
| 1138 | [ | 1155 | [ |
| 1139 | "--file-selection", | 1156 | "--file-selection", |
| 1140 | &format!("--title={title}"), | 1157 | &format!("--title={title}"), |
| 1141 | &format!("--file-filter={patterns}"), | 1158 | &format!("--file-filter={patterns}"), |
| 1142 | ], | 1159 | ], |
| 1143 | ["--getopenfilename", ".", &patterns, "--title", title], | 1160 | ["--getopenfilename", ".", &patterns, "--title", title], |
| 1144 | ); | 1161 | )) |
| 1145 | asked | ||
| 1146 | .unwrap_or_else(|error| { | ||
| 1147 | eprintln!("{error}"); | ||
| 1148 | None | ||
| 1149 | }) | ||
| 1150 | .filter(|path| !path.is_empty()) | ||
| 1151 | .map(PathBuf::from) | ||
| 1152 | } | 1162 | } |
| 1153 | 1163 | ||
| 1154 | /// No icon theme lookup; the page draws a blank page for the file. | 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,27 +1220,20 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool |
| 1210 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when | 1220 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when |
| 1211 | /// cancelled or when no tool can ask. | 1221 | /// cancelled or when no tool can ask. |
| 1212 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | 1222 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1213 | let asked = dialog( | 1223 | picked(dialog( |
| 1214 | [ | 1224 | [ |
| 1215 | "--file-selection", | 1225 | "--file-selection", |
| 1216 | &format!("--title={title}"), | 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 | "--getopenfilename", | 1230 | "--getopenfilename", |
| 1221 | ".", | 1231 | ".", |
| 1222 | "*.onetoc2 *.one", | 1232 | "*.onetoc2 *.one *.onepkg", |
| 1223 | "--title", | 1233 | "--title", |
| 1224 | title, | 1234 | title, |
| 1225 | ], | 1235 | ], |
| 1226 | ); | 1236 | )) |
| 1227 | asked | ||
| 1228 | .unwrap_or_else(|error| { | ||
| 1229 | eprintln!("{error}"); | ||
| 1230 | None | ||
| 1231 | }) | ||
| 1232 | .filter(|path| !path.is_empty()) | ||
| 1233 | .map(PathBuf::from) | ||
| 1234 | } | 1237 | } |
| 1235 | 1238 | ||
| 1236 | /// Asks where to put something named `name` by default; the dialog names its own button. | 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,7 +1243,7 @@ pub fn pick_new( |
| 1240 | _action: &str, | 1243 | _action: &str, |
| 1241 | _folder: Option<&std::path::Path>, | 1244 | _folder: Option<&std::path::Path>, |
| 1242 | ) -> Option<PathBuf> { | 1245 | ) -> Option<PathBuf> { |
| 1243 | let asked = dialog( | 1246 | picked(dialog( |
| 1244 | [ | 1247 | [ |
| 1245 | "--file-selection", | 1248 | "--file-selection", |
| 1246 | "--save", | 1249 | "--save", |
| ... | @@ -1248,14 +1251,7 @@ pub fn pick_new( | ... | @@ -1248,14 +1251,7 @@ pub fn pick_new( |
| 1248 | &format!("--filename={name}"), | 1251 | &format!("--filename={name}"), |
| 1249 | ], | 1252 | ], |
| 1250 | ["--getsavefilename", name, "--title", title], | 1253 | ["--getsavefilename", name, "--title", title], |
| 1251 | ); | 1254 | )) |
| 1252 | asked | ||
| 1253 | .unwrap_or_else(|error| { | ||
| 1254 | eprintln!("{error}"); | ||
| 1255 | None | ||
| 1256 | }) | ||
| 1257 | .filter(|path| !path.is_empty()) | ||
| 1258 | .map(PathBuf::from) | ||
| 1259 | } | 1255 | } |
| 1260 | 1256 | ||
| 1261 | /// Tells the user something they asked for could not be done: `message`, then what to do. | 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,7 +37,7 @@ pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { |
| 37 | 37 | ||
| 38 | pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; | 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 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); | 41 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 42 | static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); | 42 | static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 43 | static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); | 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,23 +71,25 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran |
| 71 | } else { | 71 | } else { |
| 72 | msg_send_id![text, copy] | 72 | msg_send_id![text, copy] |
| 73 | }; | 73 | }; |
| 74 | if let Some(proxy) = QUIT.get() { | 74 | if let Some(proxy) = PROXY.get() { |
| 75 | let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string())); | 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 | let RawWindowHandle::AppKit(handle) = | 83 | let RawWindowHandle::AppKit(handle) = |
| 83 | window.window_handle().expect("Live AppKit window").as_raw() | 84 | window.window_handle().expect("Live AppKit window").as_raw() |
| 84 | else { | 85 | else { |
| 85 | unreachable!() | 86 | unreachable!() |
| 86 | }; | 87 | }; |
| 87 | unsafe { | 88 | unsafe { Retained::retain(handle.ns_view.as_ptr().cast()) }.expect("A live window has a view") |
| 88 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | 89 | } |
| 89 | msg_send_id![view, window] | 90 | |
| 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 | /// Room the traffic lights take at the title bar's leading edge. | 95 | /// Room the traffic lights take at the title bar's leading edge. |
| ... | @@ -107,11 +109,11 @@ pub fn corner_radius(_: &Window) -> f32 { | ... | @@ -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 | /// Winit's theme already holds the window to the app's appearance. | 112 | /// Winit's theme already holds the window to the app's appearance. |
| 113 | pub fn follow_appearance(_: &Window, _: winit::window::Theme) {} | 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 | pub fn cuts_corners() -> bool { | 117 | pub fn cuts_corners() -> bool { |
| 116 | crate::aqua::before_lion() | 118 | crate::aqua::before_lion() |
| 117 | } | 119 | } |
| ... | @@ -332,13 +334,8 @@ pub fn install_backdrop(window: &Window) -> bool { | ... | @@ -332,13 +334,8 @@ pub fn install_backdrop(window: &Window) -> bool { |
| 332 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { | 334 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { |
| 333 | return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); | 335 | return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); |
| 334 | }; | 336 | }; |
| 335 | let RawWindowHandle::AppKit(handle) = | 337 | let view = &*ns_view(window); |
| 336 | window.window_handle().expect("Live AppKit window").as_raw() | ||
| 337 | else { | ||
| 338 | unreachable!() | ||
| 339 | }; | ||
| 340 | unsafe { | 338 | unsafe { |
| 341 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | ||
| 342 | let class = BACKDROP_CLASS.get_or_init(|| { | 339 | let class = BACKDROP_CLASS.get_or_init(|| { |
| 343 | let mut class = | 340 | let mut class = |
| 344 | ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); | 341 | ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); |
| ... | @@ -881,6 +878,11 @@ pub fn appearance(window: &Window) -> winit::window::Theme { | ... | @@ -881,6 +878,11 @@ pub fn appearance(window: &Window) -> winit::window::Theme { |
| 881 | window.theme().unwrap_or(winit::window::Theme::Dark) | 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 | /// Zooms the window once the current event is handled: AppKit's zoom animation runs its | 886 | /// Zooms the window once the current event is handled: AppKit's zoom animation runs its |
| 885 | /// own loop, and started from inside winit's handler it would hold every resize until the | 887 | /// own loop, and started from inside winit's handler it would hold every resize until the |
| 886 | /// end, stretching the last frame instead of drawing each step. | 888 | /// end, stretching the last frame instead of drawing each step. |
| ... | @@ -899,13 +901,8 @@ pub fn zoom(window: &Window) { | ... | @@ -899,13 +901,8 @@ pub fn zoom(window: &Window) { |
| 899 | /// Install before AccessKit subclasses the same view, preserving its restoration chain. | 901 | /// Install before AccessKit subclasses the same view, preserving its restoration chain. |
| 900 | pub fn install_text_input(window: &Window) { | 902 | pub fn install_text_input(window: &Window) { |
| 901 | MainThreadMarker::new().expect("Text input belongs to the main thread"); | 903 | MainThreadMarker::new().expect("Text input belongs to the main thread"); |
| 902 | let RawWindowHandle::AppKit(handle) = | 904 | let view = &*ns_view(window); |
| 903 | window.window_handle().expect("Live AppKit window").as_raw() | ||
| 904 | else { | ||
| 905 | unreachable!() | ||
| 906 | }; | ||
| 907 | unsafe { | 905 | unsafe { |
| 908 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | ||
| 909 | let class = INPUT_CLASS.get_or_init(|| { | 906 | let class = INPUT_CLASS.get_or_init(|| { |
| 910 | let mut class = ClassBuilder::new("SnowboundTextInputView", view.class()) | 907 | let mut class = ClassBuilder::new("SnowboundTextInputView", view.class()) |
| 911 | .expect("Unique text input class"); | 908 | .expect("Unique text input class"); |
| ... | @@ -965,9 +962,7 @@ pub fn edit_date( | ... | @@ -965,9 +962,7 @@ pub fn edit_date( |
| 965 | } | 962 | } |
| 966 | picker.setCalendar(Some(&calendar)); | 963 | picker.setCalendar(Some(&calendar)); |
| 967 | picker.setTimeZone(Some(&calendar.timeZone())); | 964 | picker.setTimeZone(Some(&calendar.timeZone())); |
| 968 | picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( | 965 | picker.setDateValue(&ns_date(timestamp)); |
| 969 | (timestamp / 10_000_000) as f64 - 11_644_473_600.0, | ||
| 970 | )); | ||
| 971 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( | 966 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( |
| 972 | -11_644_473_600.0, | 967 | -11_644_473_600.0, |
| 973 | ))); | 968 | ))); |
| ... | @@ -996,9 +991,7 @@ fn merge_date( | ... | @@ -996,9 +991,7 @@ fn merge_date( |
| 996 | calendar: &NSCalendar, | 991 | calendar: &NSCalendar, |
| 997 | ) -> Result<(u64, [String; 2]), &'static str> { | 992 | ) -> Result<(u64, [String; 2]), &'static str> { |
| 998 | unsafe { | 993 | unsafe { |
| 999 | let original = NSDate::dateWithTimeIntervalSince1970( | 994 | let original = ns_date(timestamp); |
| 1000 | (timestamp / 10_000_000) as f64 - 11_644_473_600.0, | ||
| 1001 | ); | ||
| 1002 | let units = NSCalendarUnit::Era | 995 | let units = NSCalendarUnit::Era |
| 1003 | | NSCalendarUnit::Year | 996 | | NSCalendarUnit::Year |
| 1004 | | NSCalendarUnit::Month | 997 | | NSCalendarUnit::Month |
| ... | @@ -1053,11 +1046,14 @@ fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] { | ... | @@ -1053,11 +1046,14 @@ fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] { |
| 1053 | 1046 | ||
| 1054 | /// FILETIME as a new page's title shows it: the long date and the short time. | 1047 | /// FILETIME as a new page's title shows it: the long date and the short time. |
| 1055 | pub fn date_text(filetime: u64) -> [String; 2] { | 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 | unsafe { | 1055 | unsafe { |
| 1057 | let date = NSDate::dateWithTimeIntervalSince1970( | 1056 | NSDate::dateWithTimeIntervalSince1970((filetime / 10_000_000) as f64 - 11_644_473_600.0) |
| 1058 | (filetime / 10_000_000) as f64 - 11_644_473_600.0, | ||
| 1059 | ); | ||
| 1060 | labels(&date, &NSCalendar::currentCalendar()) | ||
| 1061 | } | 1057 | } |
| 1062 | } | 1058 | } |
| 1063 | 1059 | ||
| ... | @@ -1069,9 +1065,7 @@ pub fn user_name() -> String { | ... | @@ -1069,9 +1065,7 @@ pub fn user_name() -> String { |
| 1069 | /// FILETIME as the system's short date, as OneNote labels a conflict page. | 1065 | /// FILETIME as the system's short date, as OneNote labels a conflict page. |
| 1070 | pub fn short_date(filetime: u64) -> String { | 1066 | pub fn short_date(filetime: u64) -> String { |
| 1071 | unsafe { | 1067 | unsafe { |
| 1072 | let date = NSDate::dateWithTimeIntervalSince1970( | 1068 | let date = ns_date(filetime); |
| 1073 | (filetime / 10_000_000) as f64 - 11_644_473_600.0, | ||
| 1074 | ); | ||
| 1075 | let formatter = NSDateFormatter::new(); | 1069 | let formatter = NSDateFormatter::new(); |
| 1076 | formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); | 1070 | formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); |
| 1077 | formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); | 1071 | formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); |
| ... | @@ -1231,13 +1225,13 @@ declare_class!( | ... | @@ -1231,13 +1225,13 @@ declare_class!( |
| 1231 | 1225 | ||
| 1232 | #[method(terminate:)] | 1226 | #[method(terminate:)] |
| 1233 | fn terminate(&self, _sender: Option<&AnyObject>) { | 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 | #[method(choose:)] | 1231 | #[method(choose:)] |
| 1238 | fn choose(&self, sender: &NSMenuItem) { | 1232 | fn choose(&self, sender: &NSMenuItem) { |
| 1239 | let tag = usize::try_from(unsafe { sender.tag() }).ok(); | 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 | let _ = proxy.send_event(crate::UserEvent::Choose(choice)); | 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,7 +1296,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 1302 | .with_activate_ignoring_other_apps(false); | 1296 | .with_activate_ignoring_other_apps(false); |
| 1303 | } | 1297 | } |
| 1304 | let event_loop = builder.build()?; | 1298 | let event_loop = builder.build()?; |
| 1305 | QUIT.set(event_loop.create_proxy()) | 1299 | PROXY |
| 1300 | .set(event_loop.create_proxy()) | ||
| 1306 | .expect("Only one application event loop is created"); | 1301 | .expect("Only one application event loop is created"); |
| 1307 | // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for | 1302 | // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for |
| 1308 | // the Finder's and `open`'s documents, before the launch finishes on a cold launch. | 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,7 +1323,7 @@ unsafe extern "C" fn open_files( |
| 1328 | app: &NSApplication, | 1323 | app: &NSApplication, |
| 1329 | files: &objc2_foundation::NSArray<NSString>, | 1324 | files: &objc2_foundation::NSArray<NSString>, |
| 1330 | ) { | 1325 | ) { |
| 1331 | if let Some(proxy) = QUIT.get() { | 1326 | if let Some(proxy) = PROXY.get() { |
| 1332 | let paths = files.iter().map(|file| file.to_string().into()).collect(); | 1327 | let paths = files.iter().map(|file| file.to_string().into()).collect(); |
| 1333 | let _ = proxy.send_event(crate::UserEvent::Open(paths)); | 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,14 +1387,9 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool |
| 1392 | /// End AppKit preedit after a canvas action commits or cancels the composition. | 1387 | /// End AppKit preedit after a canvas action commits or cancels the composition. |
| 1393 | pub fn clear_marked_text(window: &Window) { | 1388 | pub fn clear_marked_text(window: &Window) { |
| 1394 | MainThreadMarker::new().expect("Text input belongs to the main thread"); | 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 | // Winit owns this view and implements the NSTextInputClient selectors. | 1390 | // Winit owns this view and implements the NSTextInputClient selectors. |
| 1391 | let view = &*ns_view(window); | ||
| 1401 | unsafe { | 1392 | unsafe { |
| 1402 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | ||
| 1403 | let marked: bool = msg_send![view, hasMarkedText]; | 1393 | let marked: bool = msg_send![view, hasMarkedText]; |
| 1404 | if marked { | 1394 | if marked { |
| 1405 | let _: () = msg_send![view, unmarkText]; | 1395 | let _: () = msg_send![view, unmarkText]; |
crates/snowbound/src/main.rs+326-231| ... | @@ -40,13 +40,16 @@ mod paste; | ... | @@ -40,13 +40,16 @@ mod paste; |
| 40 | mod platform; | 40 | mod platform; |
| 41 | mod prefetch; | 41 | mod prefetch; |
| 42 | mod print; | 42 | mod print; |
| 43 | mod properties; | ||
| 44 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] | 43 | #[cfg_attr(target_os = "linux", path = "print_linux.rs")] |
| 45 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] | 44 | #[cfg_attr(target_os = "macos", path = "print_macos.rs")] |
| 46 | #[cfg_attr(windows, path = "print_windows.rs")] | 45 | #[cfg_attr(windows, path = "print_windows.rs")] |
| 47 | mod printer; | 46 | mod printer; |
| 47 | mod properties; | ||
| 48 | mod protection; | ||
| 48 | mod recording; | 49 | mod recording; |
| 50 | mod recycle; | ||
| 49 | mod rename; | 51 | mod rename; |
| 52 | mod save_as; | ||
| 50 | mod screenshot; | 53 | mod screenshot; |
| 51 | mod search; | 54 | mod search; |
| 52 | mod server; | 55 | mod server; |
| ... | @@ -64,6 +67,8 @@ mod tags; | ... | @@ -64,6 +67,8 @@ mod tags; |
| 64 | mod templates; | 67 | mod templates; |
| 65 | mod themes; | 68 | mod themes; |
| 66 | mod undo; | 69 | mod undo; |
| 70 | mod unpack; | ||
| 71 | mod unread; | ||
| 67 | mod update; | 72 | mod update; |
| 68 | mod video; | 73 | mod video; |
| 69 | mod watch; | 74 | mod watch; |
| ... | @@ -106,6 +111,12 @@ use winit::{ | ... | @@ -106,6 +111,12 @@ use winit::{ |
| 106 | const TITLE: f32 = 38.0; | 111 | const TITLE: f32 = 38.0; |
| 107 | /// Space between toolbar groups. | 112 | /// Space between toolbar groups. |
| 108 | const GAP: f32 = 6.0; | 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 | const TAB_ROW: f32 = 28.0; | 120 | const TAB_ROW: f32 = 28.0; |
| 110 | /// Width of the section colour around the page, and the rows' margin at the window's sides. | 121 | /// Width of the section colour around the page, and the rows' margin at the window's sides. |
| 111 | const FRAME: f32 = 6.0; | 122 | const FRAME: f32 = 6.0; |
| ... | @@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0; | ... | @@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0; |
| 115 | const MIN_SIZE: [f32; 2] = [600.0, 400.0]; | 126 | const MIN_SIZE: [f32; 2] = [600.0, 400.0]; |
| 116 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. | 127 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. |
| 117 | const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; | 128 | const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; |
| 118 | /// Font sizes the size box offers, OneNote's list in points. | ||
| 119 | /// Fonts the font box offers first, OneNote's default leading. | 129 | /// Fonts the font box offers first, OneNote's default leading. |
| 120 | const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; | 130 | const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; |
| 121 | /// Picks the font box and the list galleries offer as recent, at most, as OneNote's | 131 | /// Picks the font box and the list galleries offer as recent, at most, as OneNote's |
| ... | @@ -579,6 +589,8 @@ enum Command { | ... | @@ -579,6 +589,8 @@ enum Command { |
| 579 | }, | 589 | }, |
| 580 | /// Deletes pages of the open section to the notebook's recycle bin. | 590 | /// Deletes pages of the open section to the notebook's recycle bin. |
| 581 | DeletePages(Vec<ExGuid>), | 591 | DeletePages(Vec<ExGuid>), |
| 592 | /// Restores or deletes for good pages of the recycle bin's open section. | ||
| 593 | Recycle(recycle::Request), | ||
| 582 | /// Moves or indents pages of the open section. | 594 | /// Moves or indents pages of the open section. |
| 583 | Pages(Vec<onestore::PageEdit>), | 595 | Pages(Vec<onestore::PageEdit>), |
| 584 | /// Moves a page of the open section to the end of another section of its folder. | 596 | /// Moves a page of the open section to the end of another section of its folder. |
| ... | @@ -672,7 +684,7 @@ struct State { | ... | @@ -672,7 +684,7 @@ struct State { |
| 672 | cache: PathBuf, | 684 | cache: PathBuf, |
| 673 | /// Whether the notebook sidebar is expanded. | 685 | /// Whether the notebook sidebar is expanded. |
| 674 | sidebar: bool, | 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 | folded: HashSet<String>, | 688 | folded: HashSet<String>, |
| 677 | /// The open context menu: what it was opened on, and where. | 689 | /// The open context menu: what it was opened on, and where. |
| 678 | menu: Option<(menus::Target, [f32; 2])>, | 690 | menu: Option<(menus::Target, [f32; 2])>, |
| ... | @@ -693,6 +705,12 @@ struct State { | ... | @@ -693,6 +705,12 @@ struct State { |
| 693 | /// The Link dialog's fields while it is open. | 705 | /// The Link dialog's fields while it is open. |
| 694 | link: Option<link::LinkDialog>, | 706 | link: Option<link::LinkDialog>, |
| 695 | properties: Option<properties::Properties>, | 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 | /// Open Notebook from Server while it is open. | 714 | /// Open Notebook from Server while it is open. |
| 697 | server: Option<server::Connect>, | 715 | server: Option<server::Connect>, |
| 698 | /// New iCloud Notebook while it is open. | 716 | /// New iCloud Notebook while it is open. |
| ... | @@ -728,6 +746,12 @@ struct State { | ... | @@ -728,6 +746,12 @@ struct State { |
| 728 | /// The notebook shown while it has no section open, as one whose last section was | 746 | /// The notebook shown while it has no section open, as one whose last section was |
| 729 | /// deleted. | 747 | /// deleted. |
| 730 | sectionless: Option<Arc<Library>>, | 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 | /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. | 755 | /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. |
| 732 | drag: Option<menus::Drag>, | 756 | drag: Option<menus::Drag>, |
| 733 | /// Pages whose template strip was dismissed this run. | 757 | /// Pages whose template strip was dismissed this run. |
| ... | @@ -879,6 +903,7 @@ impl State { | ... | @@ -879,6 +903,7 @@ impl State { |
| 879 | let mut notebooks = Vec::new(); | 903 | let mut notebooks = Vec::new(); |
| 880 | let mut session = None; | 904 | let mut session = None; |
| 881 | let mut sectionless = None; | 905 | let mut sectionless = None; |
| 906 | let mut locked = None; | ||
| 882 | let (editor, scene) = match input { | 907 | let (editor, scene) = match input { |
| 883 | Input::Notes { | 908 | Input::Notes { |
| 884 | document, | 909 | document, |
| ... | @@ -933,7 +958,7 @@ impl State { | ... | @@ -933,7 +958,7 @@ impl State { |
| 933 | .chain(&notebooks) | 958 | .chain(&notebooks) |
| 934 | .find_map(|library| Some((library, library.first_section()?))); | 959 | .find_map(|library| Some((library, library.first_section()?))); |
| 935 | match shown { | 960 | match shown { |
| 936 | Some((library, path)) => { | 961 | Some((library, path)) if !library.locked(&path) => { |
| 937 | let section = library.open(&path, notify(proxy.clone()))?; | 962 | let section = library.open(&path, notify(proxy.clone()))?; |
| 938 | let (opened, page) = | 963 | let (opened, page) = |
| 939 | read_session(section, Arc::clone(library), path, None)?; | 964 | read_session(section, Arc::clone(library), path, None)?; |
| ... | @@ -941,11 +966,17 @@ impl State { | ... | @@ -941,11 +966,17 @@ impl State { |
| 941 | session = Some(opened); | 966 | session = Some(opened); |
| 942 | (editor, Some((scene, [0.0; 2]))) | 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 | sectionless = notebooks | 975 | sectionless = notebooks |
| 946 | .iter() | 976 | .iter() |
| 947 | .find(|library| Some(&library.location) == current.as_ref()) | 977 | .find(|library| Some(&library.location) == current.as_ref()) |
| 948 | .or(notebooks.first()) | 978 | .or(notebooks.first()) |
| 979 | .filter(|_| locked.is_none()) | ||
| 949 | .cloned(); | 980 | .cloned(); |
| 950 | let blank = TextDocument::new(vec![Paragraph::new( | 981 | let blank = TextDocument::new(vec![Paragraph::new( |
| 951 | String::new(), | 982 | String::new(), |
| ... | @@ -1014,6 +1045,7 @@ impl State { | ... | @@ -1014,6 +1045,7 @@ impl State { |
| 1014 | .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); | 1045 | .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); |
| 1015 | let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); | 1046 | let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); |
| 1016 | let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); | 1047 | let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); |
| 1048 | let reads = unread::Reads::load(&cache); | ||
| 1017 | let mut state = Self { | 1049 | let mut state = Self { |
| 1018 | author: stored.user_name.unwrap_or_else(platform::user_name), | 1050 | author: stored.user_name.unwrap_or_else(platform::user_name), |
| 1019 | window, | 1051 | window, |
| ... | @@ -1045,6 +1077,9 @@ impl State { | ... | @@ -1045,6 +1077,9 @@ impl State { |
| 1045 | after_open: None, | 1077 | after_open: None, |
| 1046 | link: None, | 1078 | link: None, |
| 1047 | properties: None, | 1079 | properties: None, |
| 1080 | save_as: None, | ||
| 1081 | unpacking: None, | ||
| 1082 | reads, | ||
| 1048 | server: None, | 1083 | server: None, |
| 1049 | new_icloud: None, | 1084 | new_icloud: None, |
| 1050 | icloud_reading: HashSet::new(), | 1085 | icloud_reading: HashSet::new(), |
| ... | @@ -1062,6 +1097,9 @@ impl State { | ... | @@ -1062,6 +1097,9 @@ impl State { |
| 1062 | titlebar, | 1097 | titlebar, |
| 1063 | renaming: None, | 1098 | renaming: None, |
| 1064 | sectionless, | 1099 | sectionless, |
| 1100 | locked, | ||
| 1101 | password: None, | ||
| 1102 | passwords: stored.passwords, | ||
| 1065 | drag: None, | 1103 | drag: None, |
| 1066 | templates: templates::View::Strip, | 1104 | templates: templates::View::Strip, |
| 1067 | media: Default::default(), | 1105 | media: Default::default(), |
| ... | @@ -1142,6 +1180,7 @@ impl State { | ... | @@ -1142,6 +1180,7 @@ impl State { |
| 1142 | eprintln!("{error}"); | 1180 | eprintln!("{error}"); |
| 1143 | } | 1181 | } |
| 1144 | lap("open", start); | 1182 | lap("open", start); |
| 1183 | self.follow_reading(); | ||
| 1145 | let size = self.window.inner_size(); | 1184 | let size = self.window.inner_size(); |
| 1146 | let scale = self.scale(); | 1185 | let scale = self.scale(); |
| 1147 | if scale != self.ui.scale() { | 1186 | if scale != self.ui.scale() { |
| ... | @@ -1170,6 +1209,7 @@ impl State { | ... | @@ -1170,6 +1209,7 @@ impl State { |
| 1170 | self.ime_allowed = ime; | 1209 | self.ime_allowed = ime; |
| 1171 | self.window.set_ime_allowed(ime); | 1210 | self.window.set_ime_allowed(ime); |
| 1172 | } | 1211 | } |
| 1212 | self.lock_idle()?; | ||
| 1173 | self.draw()?; | 1213 | self.draw()?; |
| 1174 | lap("drawn", start); | 1214 | lap("drawn", start); |
| 1175 | self.sync_index(false, Vec::new()); | 1215 | self.sync_index(false, Vec::new()); |
| ... | @@ -1211,6 +1251,9 @@ impl State { | ... | @@ -1211,6 +1251,9 @@ impl State { |
| 1211 | self.new_icloud_dialog(); | 1251 | self.new_icloud_dialog(); |
| 1212 | self.link_dialog()?; | 1252 | self.link_dialog()?; |
| 1213 | self.properties_dialog(); | 1253 | self.properties_dialog(); |
| 1254 | self.save_as_dialog(); | ||
| 1255 | self.unpack_dialog(); | ||
| 1256 | self.password_dialog()?; | ||
| 1214 | self.customize_tags(); | 1257 | self.customize_tags(); |
| 1215 | self.palette(); | 1258 | self.palette(); |
| 1216 | self.sync_popup()?; | 1259 | self.sync_popup()?; |
| ... | @@ -1314,7 +1357,10 @@ impl State { | ... | @@ -1314,7 +1357,10 @@ impl State { |
| 1314 | let mut theme = self.ui.theme.clone(); | 1357 | let mut theme = self.ui.theme.clone(); |
| 1315 | theme.paper = self.paper().color; | 1358 | theme.paper = self.paper().color; |
| 1316 | platform::update_menu(|| self.statuses()); | 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 | self.toolbar(&theme, !welcome)?; | 1364 | self.toolbar(&theme, !welcome)?; |
| 1319 | if welcome { | 1365 | if welcome { |
| 1320 | self.welcome(&theme); | 1366 | self.welcome(&theme); |
| ... | @@ -1380,7 +1426,9 @@ impl State { | ... | @@ -1380,7 +1426,9 @@ impl State { |
| 1380 | ..Spec::default() | 1426 | ..Spec::default() |
| 1381 | }, | 1427 | }, |
| 1382 | ); | 1428 | ); |
| 1429 | self.recycle_heading(&theme); | ||
| 1383 | let row = sections(); | 1430 | let row = sections(); |
| 1431 | let unread = self.unread_keys(); | ||
| 1384 | let (clicked, open_tab) = match &self.session { | 1432 | let (clicked, open_tab) = match &self.session { |
| 1385 | Some(session) => { | 1433 | Some(session) => { |
| 1386 | // A tab being renamed takes the name typed, which its field covers. | 1434 | // A tab being renamed takes the name typed, which its field covers. |
| ... | @@ -1394,7 +1442,8 @@ impl State { | ... | @@ -1394,7 +1442,8 @@ impl State { |
| 1394 | } | 1442 | } |
| 1395 | _ => tab.name.as_str(), | 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 | .collect(); | 1448 | .collect(); |
| 1400 | let lit = self.page_drop(row); | 1449 | let lit = self.page_drop(row); |
| ... | @@ -1453,8 +1502,9 @@ impl State { | ... | @@ -1453,8 +1502,9 @@ impl State { |
| 1453 | self.tab_rename_field(&theme, row, tab_row); | 1502 | self.tab_rename_field(&theme, row, tab_row); |
| 1454 | (clicked, open_tab) | 1503 | (clicked, open_tab) |
| 1455 | } | 1504 | } |
| 1505 | None if self.locked.is_some() => self.locked_tabs(row, &section, theme.strip), | ||
| 1456 | None => { | 1506 | None => { |
| 1457 | let tabs = [("Temporary page", section_color(None))]; | 1507 | let tabs = [("Temporary page", section_color(None), false)]; |
| 1458 | let shown = if self.temporary { &tabs[..] } else { &[] }; | 1508 | let shown = if self.temporary { &tabs[..] } else { &[] }; |
| 1459 | let open_tab = ui::shell::section_tabs( | 1509 | let open_tab = ui::shell::section_tabs( |
| 1460 | &mut self.ui, | 1510 | &mut self.ui, |
| ... | @@ -1537,6 +1587,22 @@ impl State { | ... | @@ -1537,6 +1587,22 @@ impl State { |
| 1537 | self.commands | 1587 | self.commands |
| 1538 | .extend(conflict_bar(&mut self.ui, bar, &sections, steps)); | 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 | if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) { | 1606 | if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) { |
| 1541 | self.no_sections(&theme, library); | 1607 | self.no_sections(&theme, library); |
| 1542 | self.ui.close(); | 1608 | self.ui.close(); |
| ... | @@ -1816,7 +1882,6 @@ impl State { | ... | @@ -1816,7 +1882,6 @@ impl State { |
| 1816 | Flags::default() | 1882 | Flags::default() |
| 1817 | }, | 1883 | }, |
| 1818 | size: [fill(), px(TITLE)], | 1884 | size: [fill(), px(TITLE)], |
| 1819 | fill: Some(theme.strip), | ||
| 1820 | pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], | 1885 | pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], |
| 1821 | gap: GAP, | 1886 | gap: GAP, |
| 1822 | role: Some(accesskit::Role::Toolbar), | 1887 | role: Some(accesskit::Role::Toolbar), |
| ... | @@ -1827,6 +1892,20 @@ impl State { | ... | @@ -1827,6 +1892,20 @@ impl State { |
| 1827 | } else { | 1892 | } else { |
| 1828 | self.ui.open("toolbar", spec) | 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 | let lead = if title { platform::LEADING } else { FRAME }; | 1909 | let lead = if title { platform::LEADING } else { FRAME }; |
| 1831 | self.ui.leaf( | 1910 | self.ui.leaf( |
| 1832 | "lead", | 1911 | "lead", |
| ... | @@ -1838,16 +1917,21 @@ impl State { | ... | @@ -1838,16 +1917,21 @@ impl State { |
| 1838 | if tools { | 1917 | if tools { |
| 1839 | // The groups fold in the room the window's controls leave, and whatever still | 1918 | // The groups fold in the room the window's controls leave, and whatever still |
| 1840 | // overflows is cut there, so the controls always show. | 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 | self.ui.open( | 1922 | self.ui.open( |
| 1843 | "tools", | 1923 | "tools", |
| 1844 | Spec { | 1924 | Spec { |
| 1845 | flags: Flags::CLIP, | 1925 | flags: Flags::CLIP, |
| 1846 | size: [fill(), px(TITLE)], | 1926 | size: [fill(), px(TITLE)], |
| 1847 | pad: [0.0, edge], | 1927 | pad: [face.pad[0], edge], |
| 1848 | offset: [0.0, -edge], | 1928 | offset: [0.0, -edge], |
| 1849 | gap: GAP, | 1929 | gap: if theme.tool[3] > 0.0 && !panel { |
| 1850 | ..Spec::default() | 1930 | FACE_GAP |
| 1931 | } else { | ||
| 1932 | GAP | ||
| 1933 | }, | ||
| 1934 | ..face | ||
| 1851 | }, | 1935 | }, |
| 1852 | ); | 1936 | ); |
| 1853 | self.tools(theme); | 1937 | self.tools(theme); |
| ... | @@ -1861,6 +1945,7 @@ impl State { | ... | @@ -1861,6 +1945,7 @@ impl State { |
| 1861 | }, | 1945 | }, |
| 1862 | ); | 1946 | ); |
| 1863 | } | 1947 | } |
| 1948 | self.ui.close(); | ||
| 1864 | if title { | 1949 | if title { |
| 1865 | platform::window_controls(&mut self.ui, &self.window); | 1950 | platform::window_controls(&mut self.ui, &self.window); |
| 1866 | } | 1951 | } |
| ... | @@ -1878,8 +1963,7 @@ impl State { | ... | @@ -1878,8 +1963,7 @@ impl State { |
| 1878 | .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); | 1963 | .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); |
| 1879 | if narrowest != self.min_width { | 1964 | if narrowest != self.min_width { |
| 1880 | self.min_width = narrowest; | 1965 | self.min_width = narrowest; |
| 1881 | self.window | 1966 | platform::set_min_size(&self.window, [narrowest, MIN_SIZE[1]]); |
| 1882 | .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1]))); | ||
| 1883 | } | 1967 | } |
| 1884 | Ok(()) | 1968 | Ok(()) |
| 1885 | } | 1969 | } |
| ... | @@ -2006,7 +2090,7 @@ impl State { | ... | @@ -2006,7 +2090,7 @@ impl State { |
| 2006 | "font", | 2090 | "font", |
| 2007 | Spec { | 2091 | Spec { |
| 2008 | gap: 1.0, | 2092 | gap: 1.0, |
| 2009 | ..Spec::default() | 2093 | ..tool_face(theme, grouped(theme), FACE_RISE) |
| 2010 | }, | 2094 | }, |
| 2011 | ); | 2095 | ); |
| 2012 | divider(ui, theme); | 2096 | divider(ui, theme); |
| ... | @@ -2705,7 +2789,7 @@ impl State { | ... | @@ -2705,7 +2789,7 @@ impl State { |
| 2705 | eprintln!("{error}"); | 2789 | eprintln!("{error}"); |
| 2706 | } | 2790 | } |
| 2707 | if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, None).clicked { | 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 | tip(&mut self.ui, commands::Id::NewPage); | 2794 | tip(&mut self.ui, commands::Id::NewPage); |
| 2711 | let toggle = if self.pages_open { | 2795 | let toggle = if self.pages_open { |
| ... | @@ -2749,9 +2833,8 @@ impl State { | ... | @@ -2749,9 +2833,8 @@ impl State { |
| 2749 | self.ui.close(); | 2833 | self.ui.close(); |
| 2750 | return None; | 2834 | return None; |
| 2751 | } | 2835 | } |
| 2752 | let found = self | 2836 | let found = self.search.found_in(&session.key()); |
| 2753 | .search | 2837 | let unread = self.unread_pages(); |
| 2754 | .found_in(&session.library.key(&session.tabs[session.tab].path)); | ||
| 2755 | let rounding = self.rounding(); | 2838 | let rounding = self.rounding(); |
| 2756 | let dragged = self.dragged_page(); | 2839 | let dragged = self.dragged_page(); |
| 2757 | let rows = page_rows( | 2840 | let rows = page_rows( |
| ... | @@ -2759,7 +2842,7 @@ impl State { | ... | @@ -2759,7 +2842,7 @@ impl State { |
| 2759 | theme, | 2842 | theme, |
| 2760 | section, | 2843 | section, |
| 2761 | session, | 2844 | session, |
| 2762 | &found, | 2845 | [&found, &unread], |
| 2763 | (rounding, self.page_tabs_left), | 2846 | (rounding, self.page_tabs_left), |
| 2764 | self.renaming.as_mut(), | 2847 | self.renaming.as_mut(), |
| 2765 | dragged, | 2848 | dragged, |
| ... | @@ -2813,7 +2896,8 @@ impl State { | ... | @@ -2813,7 +2896,8 @@ impl State { |
| 2813 | let response = match event { | 2896 | let response = match event { |
| 2814 | ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, | 2897 | ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, |
| 2815 | ui::Event::Pressure(pressure) => { | 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 | continue; | 2901 | continue; |
| 2818 | } | 2902 | } |
| 2819 | ui::Event::PointerLeft => self.view.pointer_left(), | 2903 | ui::Event::PointerLeft => self.view.pointer_left(), |
| ... | @@ -2900,6 +2984,9 @@ impl State { | ... | @@ -2900,6 +2984,9 @@ impl State { |
| 2900 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { | 2984 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { |
| 2901 | match command { | 2985 | match command { |
| 2902 | Command::OpenSection(library, path) => { | 2986 | Command::OpenSection(library, path) => { |
| 2987 | if library.locked(&path) { | ||
| 2988 | return self.show_locked(library, path); | ||
| 2989 | } | ||
| 2903 | let tab = match &self.session { | 2990 | let tab = match &self.session { |
| 2904 | // Back to the open section before another one opened. | 2991 | // Back to the open section before another one opened. |
| 2905 | Some(session) | 2992 | Some(session) |
| ... | @@ -2953,7 +3040,7 @@ impl State { | ... | @@ -2953,7 +3040,7 @@ impl State { |
| 2953 | self.commands.push(Command::OpenPage(page)); | 3040 | self.commands.push(Command::OpenPage(page)); |
| 2954 | } | 3041 | } |
| 2955 | Command::CopyVersion { version, tab } => { | 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 | let page = match session.version { | 3044 | let page = match session.version { |
| 2958 | Some(open) if open == version => { | 3045 | Some(open) if open == version => { |
| 2959 | session.section.version(session.space, open)? | 3046 | session.section.version(session.space, open)? |
| ... | @@ -2962,6 +3049,8 @@ impl State { | ... | @@ -2962,6 +3049,8 @@ impl State { |
| 2962 | }; | 3049 | }; |
| 2963 | if tab == session.tab { | 3050 | if tab == session.tab { |
| 2964 | session.section.import_page(&page, &self.author)?; | 3051 | session.section.import_page(&page, &self.author)?; |
| 3052 | session.pages = session.section.pages()?; | ||
| 3053 | self.edited(Vec::new()); | ||
| 2965 | } else { | 3054 | } else { |
| 2966 | let library = Arc::clone(&session.library); | 3055 | let library = Arc::clone(&session.library); |
| 2967 | let path = session.tabs[tab].path.clone(); | 3056 | let path = session.tabs[tab].path.clone(); |
| ... | @@ -3010,6 +3099,7 @@ impl State { | ... | @@ -3010,6 +3099,7 @@ impl State { |
| 3010 | self.delete_pages(pages)?; | 3099 | self.delete_pages(pages)?; |
| 3011 | self.edited(Vec::new()); | 3100 | self.edited(Vec::new()); |
| 3012 | } | 3101 | } |
| 3102 | Command::Recycle(request) => self.recycle(request)?, | ||
| 3013 | Command::Pages(edits) => self.edit_pages(edits)?, | 3103 | Command::Pages(edits) => self.edit_pages(edits)?, |
| 3014 | Command::MovePage { space, path } => { | 3104 | Command::MovePage { space, path } => { |
| 3015 | self.move_page(space, path)?; | 3105 | self.move_page(space, path)?; |
| ... | @@ -3108,11 +3198,7 @@ impl State { | ... | @@ -3108,11 +3198,7 @@ impl State { |
| 3108 | let Some(mut opening) = self.opening.take() else { | 3198 | let Some(mut opening) = self.opening.take() else { |
| 3109 | return Ok(()); | 3199 | return Ok(()); |
| 3110 | }; | 3200 | }; |
| 3111 | let paper = canvas::gpu::Paper { | 3201 | let paper = paper.colored(opening.editor.page_color()); |
| 3112 | color: self.ui.theme.paper, | ||
| 3113 | ink: self.ui.theme.paper_ink, | ||
| 3114 | } | ||
| 3115 | .colored(opening.editor.page_color()); | ||
| 3116 | if !self | 3202 | if !self |
| 3117 | .view | 3203 | .view |
| 3118 | .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) | 3204 | .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) |
| ... | @@ -3151,23 +3237,15 @@ impl State { | ... | @@ -3151,23 +3237,15 @@ impl State { |
| 3151 | .session | 3237 | .session |
| 3152 | .as_ref() | 3238 | .as_ref() |
| 3153 | .is_none_or(|open| open.library.location != session.library.location); | 3239 | .is_none_or(|open| open.library.location != session.library.location); |
| 3154 | // A notebook read again after a change replaces the one it was. | 3240 | self.list(&session.library); |
| 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 | } | ||
| 3166 | // The section left stays open a while for coming back to; the notebook's | 3241 | // The section left stays open a while for coming back to; the notebook's |
| 3167 | // background sync takes it over once it is closed. | 3242 | // background sync takes it over once it is closed. |
| 3243 | self.locked = None; | ||
| 3168 | if let Some(previous) = self.session.replace(*session) { | 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 | let library = &previous.library; | 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 | library.keep(&previous.tabs[previous.tab].path, previous.section); | 3249 | library.keep(&previous.tabs[previous.tab].path, previous.section); |
| 3172 | } else { | 3250 | } else { |
| 3173 | let section = previous.section; | 3251 | let section = previous.section; |
| ... | @@ -3197,7 +3275,7 @@ impl State { | ... | @@ -3197,7 +3275,7 @@ impl State { |
| 3197 | } | 3275 | } |
| 3198 | } | 3276 | } |
| 3199 | let place = self.session.as_ref().and_then(|session| { | 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 | self.places.get(&(key, session.space)).copied() | 3279 | self.places.get(&(key, session.space)).copied() |
| 3202 | }); | 3280 | }); |
| 3203 | let left = self.view.open(opening.editor, Some(opening.scene), place); | 3281 | let left = self.view.open(opening.editor, Some(opening.scene), place); |
| ... | @@ -3217,7 +3295,10 @@ impl State { | ... | @@ -3217,7 +3295,10 @@ impl State { |
| 3217 | self.switching = None; | 3295 | self.switching = None; |
| 3218 | self.visited(); | 3296 | self.visited(); |
| 3219 | if let Some((space, action)) = self.after_open.take() | 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 | let target = menus::Target::Page { | 3303 | let target = menus::Target::Page { |
| 3223 | library: Arc::clone(&session.library), | 3304 | library: Arc::clone(&session.library), |
| ... | @@ -3259,17 +3340,7 @@ impl State { | ... | @@ -3259,17 +3340,7 @@ impl State { |
| 3259 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. | 3340 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. |
| 3260 | /// Without `path` the notebook has no sections left, and one of its shows none. | 3341 | /// Without `path` the notebook has no sections left, and one of its shows none. |
| 3261 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { | 3342 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 3262 | if library.in_icloud() && library.downloading() { | 3343 | self.list(&library); |
| 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 | } | ||
| 3273 | let shown = |shown: &Library| shown.location == library.location; | 3344 | let shown = |shown: &Library| shown.location == library.location; |
| 3274 | let Some(path) = path else { | 3345 | let Some(path) = path else { |
| 3275 | if self.sectionless.as_deref().is_some_and(shown) | 3346 | if self.sectionless.as_deref().is_some_and(shown) |
| ... | @@ -3317,6 +3388,18 @@ impl State { | ... | @@ -3317,6 +3388,18 @@ impl State { |
| 3317 | Ok(()) | 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 | /// Follows a page shown in place of another. | 3403 | /// Follows a page shown in place of another. |
| 3321 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { | 3404 | fn opened(&mut self) -> Result<(), Box<dyn Error>> { |
| 3322 | if let Err(error) = self.wear_theme() { | 3405 | if let Err(error) = self.wear_theme() { |
| ... | @@ -3369,7 +3452,14 @@ impl State { | ... | @@ -3369,7 +3452,14 @@ impl State { |
| 3369 | .file_name() | 3452 | .file_name() |
| 3370 | .map(|name| name.to_string_lossy().into_owned()) | 3453 | .map(|name| name.to_string_lossy().into_owned()) |
| 3371 | .unwrap_or_default(); | 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 | None if !self.temporary => "Snowbound".to_owned(), | 3464 | None if !self.temporary => "Snowbound".to_owned(), |
| 3375 | None => { | 3465 | None => { |
| ... | @@ -3486,7 +3576,10 @@ impl State { | ... | @@ -3486,7 +3576,10 @@ impl State { |
| 3486 | loop { | 3576 | loop { |
| 3487 | session.section.wake(); | 3577 | session.section.wake(); |
| 3488 | if Instant::now() >= deadline | 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 | return; | 3584 | return; |
| 3492 | } | 3585 | } |
| ... | @@ -3575,16 +3668,17 @@ impl State { | ... | @@ -3575,16 +3668,17 @@ impl State { |
| 3575 | /// last poll. | 3668 | /// last poll. |
| 3576 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { | 3669 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { |
| 3577 | // Every notebook's changes are taken, so none is reported again. | 3670 | // Every notebook's changes are taken, so none is reported again. |
| 3578 | let changed: Vec<String> = self | 3671 | let reported: Vec<(Arc<Library>, Vec<String>)> = (self.notebooks.iter()) |
| 3579 | .notebooks | 3672 | .filter_map(|library| { |
| 3580 | .iter() | 3673 | Some((Arc::clone(library), library.background.as_ref()?.changed())) |
| 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)) | ||
| 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 | .collect(); | 3682 | .collect(); |
| 3589 | if !changed.is_empty() { | 3683 | if !changed.is_empty() { |
| 3590 | self.sync_index(false, changed); | 3684 | self.sync_index(false, changed); |
| ... | @@ -3597,17 +3691,16 @@ impl State { | ... | @@ -3597,17 +3691,16 @@ impl State { |
| 3597 | let mut listed = false; | 3691 | let mut listed = false; |
| 3598 | let mut changed = false; | 3692 | let mut changed = false; |
| 3599 | let mut rejected = None; | 3693 | let mut rejected = None; |
| 3694 | let mut elsewhere = Vec::new(); | ||
| 3600 | for event in session.section.events() { | 3695 | for event in session.section.events() { |
| 3601 | use notebook::session::Event; | 3696 | use notebook::session::Event; |
| 3602 | match event { | 3697 | match event { |
| 3603 | Event::Changed(spaces) => { | 3698 | Event::Changed(spaces) => { |
| 3604 | listed = true; | 3699 | listed = true; |
| 3605 | changed |= spaces.contains(&session.space); | 3700 | changed |= spaces.contains(&session.space); |
| 3606 | self.search.changed( | 3701 | elsewhere.extend_from_slice(&spaces); |
| 3607 | session.library.key(&session.tabs[session.tab].path), | 3702 | self.search |
| 3608 | session.section.replica(), | 3703 | .changed(session.key(), session.section.replica(), spaces); |
| 3609 | spaces, | ||
| 3610 | ); | ||
| 3611 | } | 3704 | } |
| 3612 | Event::Rejected { spaces, error } => { | 3705 | Event::Rejected { spaces, error } => { |
| 3613 | if spaces.contains(&session.space) { | 3706 | if spaces.contains(&session.space) { |
| ... | @@ -3621,6 +3714,12 @@ impl State { | ... | @@ -3621,6 +3714,12 @@ impl State { |
| 3621 | } | 3714 | } |
| 3622 | } | 3715 | } |
| 3623 | session.sync = session.section.sync_status()?; | 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 | if listed { | 3723 | if listed { |
| 3625 | let back = session.shown; | 3724 | let back = session.shown; |
| 3626 | session.pages = session.section.pages()?; | 3725 | session.pages = session.section.pages()?; |
| ... | @@ -3967,9 +4066,15 @@ impl State { | ... | @@ -3967,9 +4066,15 @@ impl State { |
| 3967 | }) | 4066 | }) |
| 3968 | .collect(); | 4067 | .collect(); |
| 3969 | trace_input(&("Draw", viewport.origin, viewport.scale)); | 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 | let start = Instant::now(); | 4075 | let start = Instant::now(); |
| 3971 | self.renderer | 4076 | self.renderer |
| 3972 | .draw(target, self.surface.size, self.ui.theme.strip, &layers) | 4077 | .draw(target, self.surface.size, clear, &layers) |
| 3973 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; | 4078 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| 3974 | lap("render", start); | 4079 | lap("render", start); |
| 3975 | Ok(()) | 4080 | Ok(()) |
| ... | @@ -3988,9 +4093,6 @@ impl State { | ... | @@ -3988,9 +4093,6 @@ impl State { |
| 3988 | self.surface.read(&self.renderer, offscreen) | 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 | fn over_page(&self) -> bool { | 4096 | fn over_page(&self) -> bool { |
| 3995 | let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); | 4097 | let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); |
| 3996 | let [x, y] = self.pointer; | 4098 | let [x, y] = self.pointer; |
| ... | @@ -4023,6 +4125,9 @@ impl State { | ... | @@ -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 | fn input(&mut self, event: ui::Event) { | 4131 | fn input(&mut self, event: ui::Event) { |
| 4027 | if let ui::Event::PointerMoved(point) = event { | 4132 | if let ui::Event::PointerMoved(point) = event { |
| 4028 | self.pointer = point; | 4133 | self.pointer = point; |
| ... | @@ -4335,8 +4440,8 @@ fn page_list_top() -> &'static str { | ... | @@ -4335,8 +4440,8 @@ fn page_list_top() -> &'static str { |
| 4335 | "top" | 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 | 4443 | /// The page list's rows, a page's conflict pages beneath it while shown, and `marked` the |
| 4339 | /// search `found` marked. A `dragged` page's tab follows the pointer over the others, which | 4444 | /// pages a search found and those unread. A `dragged` page's tab follows the pointer over the others, which |
| 4340 | /// slide aside to open the gap it would land in; once let go it eases into its place. | 4445 | /// slide aside to open the gap it would land in; once let go it eases into its place. |
| 4341 | #[allow(clippy::too_many_arguments)] | 4446 | #[allow(clippy::too_many_arguments)] |
| 4342 | fn page_rows( | 4447 | fn page_rows( |
| ... | @@ -4344,7 +4449,7 @@ fn page_rows( | ... | @@ -4344,7 +4449,7 @@ fn page_rows( |
| 4344 | theme: &Theme, | 4449 | theme: &Theme, |
| 4345 | section: &ui::Section, | 4450 | section: &ui::Section, |
| 4346 | session: &Session, | 4451 | session: &Session, |
| 4347 | found: &HashSet<ExGuid>, | 4452 | marked: [&HashSet<ExGuid>; 2], |
| 4348 | shape: (f32, bool), | 4453 | shape: (f32, bool), |
| 4349 | mut renaming: Option<&mut rename::Renaming>, | 4454 | mut renaming: Option<&mut rename::Renaming>, |
| 4350 | dragged: Option<PageDrag>, | 4455 | dragged: Option<PageDrag>, |
| ... | @@ -4389,7 +4494,7 @@ fn page_rows( | ... | @@ -4389,7 +4494,7 @@ fn page_rows( |
| 4389 | theme, | 4494 | theme, |
| 4390 | section, | 4495 | section, |
| 4391 | session, | 4496 | session, |
| 4392 | found, | 4497 | marked, |
| 4393 | shape, | 4498 | shape, |
| 4394 | renaming.as_deref_mut(), | 4499 | renaming.as_deref_mut(), |
| 4395 | (space, title, *level), | 4500 | (space, title, *level), |
| ... | @@ -4416,7 +4521,7 @@ fn page_rows( | ... | @@ -4416,7 +4521,7 @@ fn page_rows( |
| 4416 | theme, | 4521 | theme, |
| 4417 | section, | 4522 | section, |
| 4418 | session, | 4523 | session, |
| 4419 | found, | 4524 | marked, |
| 4420 | shape, | 4525 | shape, |
| 4421 | renaming, | 4526 | renaming, |
| 4422 | (space, title, *level), | 4527 | (space, title, *level), |
| ... | @@ -4437,7 +4542,7 @@ fn page_row( | ... | @@ -4437,7 +4542,7 @@ fn page_row( |
| 4437 | theme: &Theme, | 4542 | theme: &Theme, |
| 4438 | section: &ui::Section, | 4543 | section: &ui::Section, |
| 4439 | session: &Session, | 4544 | session: &Session, |
| 4440 | found: &HashSet<ExGuid>, | 4545 | [found, unread]: [&HashSet<ExGuid>; 2], |
| 4441 | shape: (f32, bool), | 4546 | shape: (f32, bool), |
| 4442 | renaming: Option<&mut rename::Renaming>, | 4547 | renaming: Option<&mut rename::Renaming>, |
| 4443 | (space, title, level): (&ExGuid, &String, u32), | 4548 | (space, title, level): (&ExGuid, &String, u32), |
| ... | @@ -4457,6 +4562,7 @@ fn page_row( | ... | @@ -4457,6 +4562,7 @@ fn page_row( |
| 4457 | indent: level.saturating_sub(1), | 4562 | indent: level.saturating_sub(1), |
| 4458 | conflicted: !versions.is_empty(), | 4563 | conflicted: !versions.is_empty(), |
| 4459 | found: found.contains(space), | 4564 | found: found.contains(space), |
| 4565 | unread: unread.contains(space), | ||
| 4460 | renaming: renaming | 4566 | renaming: renaming |
| 4461 | .filter(|renaming| renaming.page(*space)) | 4567 | .filter(|renaming| renaming.page(*space)) |
| 4462 | .map(|renaming| &mut renaming.name), | 4568 | .map(|renaming| &mut renaming.name), |
| ... | @@ -4493,62 +4599,45 @@ fn page_row( | ... | @@ -4493,62 +4599,45 @@ fn page_row( |
| 4493 | tab: ui::mix(section.tab, theme.chip, 0.6), | 4599 | tab: ui::mix(section.tab, theme.chip, 0.6), |
| 4494 | ..*section | 4600 | ..*section |
| 4495 | }; | 4601 | }; |
| 4496 | if session.shown_history == Some(*space) { | 4602 | // Each row: its id, its label, whether it is open, and whether it is a page version. |
| 4497 | for version in session.page_versions(*space) { | 4603 | let history = (session.page_versions(*space).iter()) |
| 4498 | let label = history::label(version); | 4604 | .filter(|_| session.shown_history == Some(*space)) |
| 4499 | let tab = PageTab { | 4605 | .map(|version| { |
| 4500 | label: &label, | 4606 | let open = session.version == Some(version.context); |
| 4501 | dim: false, | 4607 | (version.context, history::label(version), open, true) |
| 4502 | indent: level.saturating_sub(1), | 4608 | }); |
| 4503 | conflicted: false, | 4609 | let conflicts = (versions.iter()) |
| 4504 | found: false, | 4610 | .filter(|_| session.shown == Some(*space)) |
| 4505 | renaming: None, | 4611 | .map(|version| { |
| 4506 | shift, | 4612 | let label = match version.created { |
| 4507 | lifted: false, | 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); | 4616 | (version.space, label, version.space == session.space, false) |
| 4510 | if selected { | 4617 | }); |
| 4511 | rows.open = Some(ui.id(version.context)); | 4618 | for (id, label, selected, version) in history.chain(conflicts) { |
| 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 | }; | ||
| 4531 | let tab = PageTab { | 4619 | let tab = PageTab { |
| 4532 | label: &label, | 4620 | label: &label, |
| 4533 | dim: false, | 4621 | dim: false, |
| 4534 | indent: level.saturating_sub(1), | 4622 | indent: level.saturating_sub(1), |
| 4535 | conflicted: false, | 4623 | conflicted: false, |
| 4536 | found: false, | 4624 | found: false, |
| 4625 | unread: false, | ||
| 4537 | renaming: None, | 4626 | renaming: None, |
| 4538 | shift, | 4627 | shift, |
| 4539 | lifted: false, | 4628 | lifted: false, |
| 4540 | }; | 4629 | }; |
| 4541 | let selected = version.space == session.space; | ||
| 4542 | if selected { | 4630 | if selected { |
| 4543 | rows.open = Some(ui.id(version.space)); | 4631 | rows.open = Some(ui.id(id)); |
| 4544 | } | 4632 | } |
| 4545 | height += ROW; | 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 | .0 | 4635 | .0 |
| 4548 | .clicked | 4636 | .clicked; |
| 4549 | && !selected | 4637 | if clicked && !selected && version { |
| 4550 | { | 4638 | rows.version = Some((*space, id)); |
| 4551 | rows.clicked = Some(version.space); | 4639 | } else if clicked && !selected { |
| 4640 | rows.clicked = Some(id); | ||
| 4552 | } | 4641 | } |
| 4553 | } | 4642 | } |
| 4554 | height | 4643 | height |
| ... | @@ -4564,6 +4653,8 @@ struct PageTab<'a> { | ... | @@ -4564,6 +4653,8 @@ struct PageTab<'a> { |
| 4564 | conflicted: bool, | 4653 | conflicted: bool, |
| 4565 | /// A search found the page, which OneNote marks yellow. | 4654 | /// A search found the page, which OneNote marks yellow. |
| 4566 | found: bool, | 4655 | found: bool, |
| 4656 | /// Another author changed it since it was last viewed, which OneNote sets bold. | ||
| 4657 | unread: bool, | ||
| 4567 | /// The name typed in the tab's rename field, while it shows one. | 4658 | /// The name typed in the tab's rename field, while it shows one. |
| 4568 | renaming: Option<&'a mut String>, | 4659 | renaming: Option<&'a mut String>, |
| 4569 | /// How far down from its place in the list it is drawn. | 4660 | /// How far down from its place in the list it is drawn. |
| ... | @@ -4652,6 +4743,7 @@ fn page_tab( | ... | @@ -4652,6 +4743,7 @@ fn page_tab( |
| 4652 | Spec { | 4743 | Spec { |
| 4653 | size: [fill(), px(ROW - ROW_GAP)], | 4744 | size: [fill(), px(ROW - ROW_GAP)], |
| 4654 | text: Some(tab.label), | 4745 | text: Some(tab.label), |
| 4746 | bold: tab.unread, | ||
| 4655 | overflow: ui::Overflow::Ellipsis, | 4747 | overflow: ui::Overflow::Ellipsis, |
| 4656 | color: Some(color), | 4748 | color: Some(color), |
| 4657 | ..Spec::default() | 4749 | ..Spec::default() |
| ... | @@ -4793,11 +4885,12 @@ fn group( | ... | @@ -4793,11 +4885,12 @@ fn group( |
| 4793 | full: impl FnOnce(&mut Ui) -> Option<commands::Choice>, | 4885 | full: impl FnOnce(&mut Ui) -> Option<commands::Choice>, |
| 4794 | folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>, | 4886 | folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>, |
| 4795 | ) -> Option<commands::Choice> { | 4887 | ) -> Option<commands::Choice> { |
| 4888 | let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE); | ||
| 4796 | ui.open( | 4889 | ui.open( |
| 4797 | part, | 4890 | part, |
| 4798 | Spec { | 4891 | Spec { |
| 4799 | fold: Some(priority), | 4892 | fold: Some(priority), |
| 4800 | ..Spec::default() | 4893 | ..face |
| 4801 | }, | 4894 | }, |
| 4802 | ); | 4895 | ); |
| 4803 | ui.open( | 4896 | ui.open( |
| ... | @@ -5010,6 +5103,26 @@ fn dropdown( | ... | @@ -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 | /// Names box `id` to assistive technology, where no tooltip does. | 5126 | /// Names box `id` to assistive technology, where no tooltip does. |
| 5014 | fn name(ui: &mut Ui, id: Id, label: &str) { | 5127 | fn name(ui: &mut Ui, id: Id, label: &str) { |
| 5015 | if let Some(node) = ui.access(id) { | 5128 | if let Some(node) = ui.access(id) { |
| ... | @@ -5179,8 +5292,33 @@ fn title(id: commands::Id) -> &'static str { | ... | @@ -5179,8 +5292,33 @@ fn title(id: commands::Id) -> &'static str { |
| 5179 | commands::command(id).title.trim_end_matches('…') | 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 | fn divider(ui: &mut Ui, theme: &Theme) { | 5318 | fn divider(ui: &mut Ui, theme: &Theme) { |
| 5319 | if grouped(theme) { | ||
| 5320 | return; | ||
| 5321 | } | ||
| 5184 | ui.leaf( | 5322 | ui.leaf( |
| 5185 | "divider", | 5323 | "divider", |
| 5186 | Spec { | 5324 | Spec { |
| ... | @@ -5312,147 +5450,103 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); | ... | @@ -5312,147 +5450,103 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); |
| 5312 | 5450 | ||
| 5313 | impl ApplicationHandler<UserEvent> for App { | 5451 | impl ApplicationHandler<UserEvent> for App { |
| 5314 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { | 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 | let event = match event { | 5462 | let event = match event { |
| 5463 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop), | ||
| 5316 | UserEvent::Picture(bytes) => { | 5464 | UserEvent::Picture(bytes) => { |
| 5317 | if let Some(state) = &mut self.state { | 5465 | if let Err(error) = state.insert_picture(bytes, None) { |
| 5318 | if let Err(error) = state.insert_picture(bytes, None) { | 5466 | eprintln!("{error}"); |
| 5319 | eprintln!("{error}"); | ||
| 5320 | } | ||
| 5321 | state.window.request_redraw(); | ||
| 5322 | } | 5467 | } |
| 5323 | return; | 5468 | return state.window.request_redraw(); |
| 5324 | } | 5469 | } |
| 5325 | UserEvent::InsertText(text) => { | 5470 | UserEvent::InsertText(text) => { |
| 5326 | if let Some(state) = &mut self.state { | 5471 | if state.ui.focused() == Some(page()) { |
| 5327 | if state.ui.focused() == Some(page()) { | 5472 | match state.view.insert_text(text) { |
| 5328 | match state.view.insert_text(text) { | 5473 | Ok(response) => state.respond(response), |
| 5329 | Ok(response) => state.respond(response), | 5474 | Err(error) => eprintln!("{error}"), |
| 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}"); | ||
| 5347 | } | 5475 | } |
| 5348 | state.window.request_redraw(); | 5476 | state.window.request_redraw(); |
| 5477 | } else { | ||
| 5478 | state.input(ui::Event::Ime(Ime::Commit(text))); | ||
| 5349 | } | 5479 | } |
| 5350 | return; | 5480 | return; |
| 5351 | } | 5481 | } |
| 5352 | UserEvent::ICloudFolder => { | 5482 | UserEvent::Then(then) => { |
| 5353 | if let Some(state) = &mut self.state { | 5483 | if let Err(error) = then(state) { |
| 5354 | state.list_icloud(); | 5484 | eprintln!("{error}"); |
| 5355 | } | 5485 | } |
| 5356 | return; | 5486 | return state.window.request_redraw(); |
| 5357 | } | 5487 | } |
| 5488 | UserEvent::ICloudFolder => return state.list_icloud(), | ||
| 5358 | UserEvent::ICloudAccount => { | 5489 | UserEvent::ICloudAccount => { |
| 5359 | if let Some(state) = &mut self.state { | 5490 | state.icloud_account_changed(); |
| 5360 | state.icloud_account_changed(); | 5491 | return state.window.request_redraw(); |
| 5361 | state.window.request_redraw(); | ||
| 5362 | } | ||
| 5363 | return; | ||
| 5364 | } | 5492 | } |
| 5365 | UserEvent::Open(paths) => { | 5493 | UserEvent::Open(paths) => { |
| 5366 | match &mut self.state { | 5494 | for path in paths { |
| 5367 | Some(state) => { | 5495 | state.open_path(&path); |
| 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), | ||
| 5377 | } | 5496 | } |
| 5378 | return; | 5497 | state.window.set_minimized(false); |
| 5498 | state.window.focus_window(); | ||
| 5499 | return state.window.request_redraw(); | ||
| 5379 | } | 5500 | } |
| 5380 | UserEvent::Update => { | 5501 | UserEvent::Update => { |
| 5381 | if let Some(state) = &mut self.state { | 5502 | state.updated(); |
| 5382 | state.updated(); | 5503 | return state.window.request_redraw(); |
| 5383 | state.window.request_redraw(); | ||
| 5384 | } | ||
| 5385 | return; | ||
| 5386 | } | 5504 | } |
| 5387 | UserEvent::Sync => { | 5505 | UserEvent::Sync => { |
| 5388 | if let Some(state) = &mut self.state | 5506 | if let Err(error) = state.synced() { |
| 5389 | && let Err(error) = state.synced() | ||
| 5390 | { | ||
| 5391 | eprintln!("{error}"); | 5507 | eprintln!("{error}"); |
| 5392 | } | 5508 | } |
| 5393 | return; | 5509 | return; |
| 5394 | } | 5510 | } |
| 5395 | UserEvent::Choose(choice) => { | 5511 | UserEvent::Choose(choice) => return state.choose(choice), |
| 5396 | if let Some(state) = &mut self.state { | ||
| 5397 | state.choose(choice); | ||
| 5398 | } | ||
| 5399 | return; | ||
| 5400 | } | ||
| 5401 | UserEvent::Appearance => { | 5512 | UserEvent::Appearance => { |
| 5402 | if let Some(state) = &mut self.state { | 5513 | state.follow_color_scheme(); |
| 5403 | state.follow_color_scheme(); | 5514 | return state.window.request_redraw(); |
| 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; | ||
| 5413 | } | 5515 | } |
| 5516 | UserEvent::Redraw => return state.window.request_redraw(), | ||
| 5414 | UserEvent::Replay(replay) => { | 5517 | UserEvent::Replay(replay) => { |
| 5415 | if let Some(state) = &mut self.state { | 5518 | match replay { |
| 5416 | match replay { | 5519 | Replay::Input(event) => state.input(event), |
| 5417 | Replay::Input(event) => state.input(event), | 5520 | Replay::Pinch(factor) => { |
| 5418 | Replay::Pinch(factor) => { | 5521 | if let Err(error) = state.pinch(factor) { |
| 5419 | if let Err(error) = state.pinch(factor) { | 5522 | eprintln!("{error}"); |
| 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)); | ||
| 5438 | } | 5523 | } |
| 5439 | Replay::Quit => { | 5524 | } |
| 5440 | self.close(event_loop); | 5525 | Replay::Snapshot(path) => state.snapshot = Some(path), |
| 5441 | return; | 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. | 5531 | Replay::Tick | Replay::Quit => {} |
| 5445 | if let Err(error) = state.frame() { | 5532 | Replay::Appearance(appearance) => { |
| 5446 | eprintln!("{error}"); | 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 | return; | 5546 | return; |
| 5450 | } | 5547 | } |
| 5451 | UserEvent::Accessibility(event) => event, | 5548 | UserEvent::Accessibility(event) => event, |
| 5452 | }; | 5549 | }; |
| 5453 | let Some(state) = &mut self.state else { | ||
| 5454 | return; | ||
| 5455 | }; | ||
| 5456 | if event.window_id != state.window.id() { | 5550 | if event.window_id != state.window.id() { |
| 5457 | return; | 5551 | return; |
| 5458 | } | 5552 | } |
| ... | @@ -5679,9 +5773,10 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E | ... | @@ -5679,9 +5773,10 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 5679 | } | 5773 | } |
| 5680 | 5774 | ||
| 5681 | /// Feeds a development script to the window from another thread, one command per line | 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 | 5776 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, |
| 5683 | /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, | 5777 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 5684 | /// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | 5778 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`, |
| 5779 | /// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | ||
| 5685 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { | 5780 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 5686 | let mut steps = Vec::new(); | 5781 | let mut steps = Vec::new(); |
| 5687 | for line in script.lines().filter(|line| !line.trim().is_empty()) { | 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,6 +27,8 @@ pub enum Structure { |
| 27 | Delete { | 27 | Delete { |
| 28 | path: String, | 28 | path: String, |
| 29 | }, | 29 | }, |
| 30 | /// Deletes for good what the recycle bin holds. | ||
| 31 | EmptyRecycleBin, | ||
| 30 | /// Moves a section or group into another folder. | 32 | /// Moves a section or group into another folder. |
| 31 | Move { | 33 | Move { |
| 32 | path: String, | 34 | path: String, |
| ... | @@ -55,6 +57,12 @@ pub enum Structure { | ... | @@ -55,6 +57,12 @@ pub enum Structure { |
| 55 | name: String, | 57 | name: String, |
| 56 | color: Option<u32>, | 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 | /// New iCloud Notebook while it is open: the name typed, and why the last was refused. | 68 | /// New iCloud Notebook while it is open: the name typed, and why the last was refused. |
| ... | @@ -234,7 +242,7 @@ impl State { | ... | @@ -234,7 +242,7 @@ impl State { |
| 234 | } | 242 | } |
| 235 | 243 | ||
| 236 | /// Applies `ops` to the open page as one edit and shows the result. | 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 | if ops.is_empty() { | 246 | if ops.is_empty() { |
| 239 | return Ok(()); | 247 | return Ok(()); |
| 240 | } | 248 | } |
| ... | @@ -247,6 +255,7 @@ impl State { | ... | @@ -247,6 +255,7 @@ impl State { |
| 247 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), | 255 | ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), |
| 248 | }, | 256 | }, |
| 249 | )?; | 257 | )?; |
| 258 | self.edited(vec![space]); | ||
| 250 | self.refresh() | 259 | self.refresh() |
| 251 | } | 260 | } |
| 252 | 261 | ||
| ... | @@ -351,22 +360,7 @@ impl State { | ... | @@ -351,22 +360,7 @@ impl State { |
| 351 | }, | 360 | }, |
| 352 | ); | 361 | ); |
| 353 | } | 362 | } |
| 354 | ui.open( | 363 | crate::buttons(ui); |
| 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 | ); | ||
| 370 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 364 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 371 | let create = ui::button(ui, "create", "Create").clicked || entered; | 365 | let create = ui::button(ui, "create", "Create").clicked || entered; |
| 372 | ui.close(); | 366 | ui.close(); |
| ... | @@ -660,23 +654,15 @@ impl State { | ... | @@ -660,23 +654,15 @@ impl State { |
| 660 | library.close_kept(); | 654 | library.close_kept(); |
| 661 | let mut notebook = library.reopen()?; | 655 | let mut notebook = library.reopen()?; |
| 662 | let names = |notebook: &Notebook, folder: &str| -> Vec<String> { | 656 | let names = |notebook: &Notebook, folder: &str| -> Vec<String> { |
| 663 | let mut folders = vec![notebook.catalog()]; | 657 | let found = crate::library::folders(notebook.catalog(), |_| true) |
| 664 | while let Some(candidate) = folders.pop() { | 658 | .into_iter() |
| 665 | if candidate.path == folder { | 659 | .find(|candidate| candidate.path == folder); |
| 666 | return candidate | 660 | found.map_or_else(Vec::new, |folder| { |
| 667 | .sections | 661 | (folder.sections.iter().map(|section| &section.path)) |
| 668 | .iter() | 662 | .chain(folder.groups.iter().map(|group| &group.path)) |
| 669 | .map(|section| section.path.clone()) | 663 | .map(|path| crate::library::entry_name(path).to_owned()) |
| 670 | .chain(candidate.groups.iter().map(|group| group.path.clone())) | 664 | .collect() |
| 671 | .map(|path| { | 665 | }) |
| 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() | ||
| 680 | }; | 666 | }; |
| 681 | // A notebook not shown stays so through a change of its colours or name. | 667 | // A notebook not shown stays so through a change of its colours or name. |
| 682 | let stays = matches!( | 668 | let stays = matches!( |
| ... | @@ -709,6 +695,11 @@ impl State { | ... | @@ -709,6 +695,11 @@ impl State { |
| 709 | notebook.delete(&path)?; | 695 | notebook.delete(&path)?; |
| 710 | (current, None) | 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 | Structure::Move { path, folder } => { | 703 | Structure::Move { path, folder } => { |
| 713 | let moved = notebook.move_entry(&path, &folder)?; | 704 | let moved = notebook.move_entry(&path, &folder)?; |
| 714 | (current.map(|current| follow(&current, &path, &moved)), None) | 705 | (current.map(|current| follow(&current, &path, &moved)), None) |
| ... | @@ -732,6 +723,24 @@ impl State { | ... | @@ -732,6 +723,24 @@ impl State { |
| 732 | notebook.set_section_color(&path, color)?; | 723 | notebook.set_section_color(&path, color)?; |
| 733 | (current, None) | 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 | Structure::Properties { name, color } => { | 744 | Structure::Properties { name, color } => { |
| 736 | if name != library.name { | 745 | if name != library.name { |
| 737 | library.set_display_name(&name)?; | 746 | library.set_display_name(&name)?; |
| ... | @@ -1187,7 +1196,8 @@ mod tests { | ... | @@ -1187,7 +1196,8 @@ mod tests { |
| 1187 | let root = temporary.join("Emptied"); | 1196 | let root = temporary.join("Emptied"); |
| 1188 | let location = root.to_str().unwrap(); | 1197 | let location = root.to_str().unwrap(); |
| 1189 | let cache = temporary.join("cache"); | 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 | notebook.create_group("", "Group").unwrap(); | 1201 | notebook.create_group("", "Group").unwrap(); |
| 1192 | notebook.create_section("Group", "Inner", &dated()).unwrap(); | 1202 | notebook.create_section("Group", "Inner", &dated()).unwrap(); |
| 1193 | 1203 |
crates/snowbound/src/media_linux.rs+2-22| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | //! with gst-libav), the default microphone records as PCM WAV, and the default camera with | 3 | //! with gst-libav), the default microphone records as PCM WAV, and the default camera with |
| 4 | //! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins. | 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 | use std::{ | 7 | use std::{ |
| 8 | cell::Cell, | 8 | cell::Cell, |
| 9 | ffi::{CStr, CString, c_char, c_int, c_void}, | 9 | ffi::{CStr, CString, c_char, c_int, c_void}, |
| ... | @@ -50,12 +50,6 @@ struct Gst { | ... | @@ -50,12 +50,6 @@ struct Gst { |
| 50 | unsafe impl Send for Gst {} | 50 | unsafe impl Send for Gst {} |
| 51 | unsafe impl Sync for Gst {} | 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 | /// GStreamer, initialized; none where it is not installed. | 53 | /// GStreamer, initialized; none where it is not installed. |
| 60 | fn gst() -> Option<&'static Gst> { | 54 | fn gst() -> Option<&'static Gst> { |
| 61 | static GST: OnceLock<Option<Gst>> = OnceLock::new(); | 55 | static GST: OnceLock<Option<Gst>> = OnceLock::new(); |
| ... | @@ -84,20 +78,6 @@ fn gst() -> Option<&'static Gst> { | ... | @@ -84,20 +78,6 @@ fn gst() -> Option<&'static Gst> { |
| 84 | .as_ref() | 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 | /// A pipeline described as `gst-launch` takes one, paused. | 81 | /// A pipeline described as `gst-launch` takes one, paused. |
| 102 | struct Pipeline { | 82 | struct Pipeline { |
| 103 | gst: &'static Gst, | 83 | gst: &'static Gst, |
| ... | @@ -191,7 +171,7 @@ impl Player { | ... | @@ -191,7 +171,7 @@ impl Player { |
| 191 | }; | 171 | }; |
| 192 | let pipeline = Pipeline::launch(&format!( | 172 | let pipeline = Pipeline::launch(&format!( |
| 193 | "playbin uri=\"{}\" video-sink=fakesink{silent}", | 173 | "playbin uri=\"{}\" video-sink=fakesink{silent}", |
| 194 | uri(path) | 174 | crate::platform::file_uri(path) |
| 195 | ))?; | 175 | ))?; |
| 196 | // Prerolling reports a file no plugin decodes as an error. | 176 | // Prerolling reports a file no plugin decodes as an error. |
| 197 | if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) { | 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,6 +67,18 @@ const MENUS: &[Item] = &[ |
| 67 | C(Id::CopyPageLink), | 67 | C(Id::CopyPageLink), |
| 68 | C(Id::ShowNotebook), | 68 | C(Id::ShowNotebook), |
| 69 | S, | 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 | C(Id::ExportPdf), | 82 | C(Id::ExportPdf), |
| 71 | S, | 83 | S, |
| 72 | C(Id::Print), | 84 | C(Id::Print), |
crates/snowbound/src/menus.rs+128-21| ... | @@ -6,6 +6,7 @@ use crate::{ | ... | @@ -6,6 +6,7 @@ use crate::{ |
| 6 | commands::{self, Choice}, | 6 | commands::{self, Choice}, |
| 7 | manage::Structure, | 7 | manage::Structure, |
| 8 | platform, | 8 | platform, |
| 9 | recycle::Request, | ||
| 9 | }; | 10 | }; |
| 10 | use onestore::{ExGuid, PageEdit}; | 11 | use onestore::{ExGuid, PageEdit}; |
| 11 | use std::sync::Arc; | 12 | use std::sync::Arc; |
| ... | @@ -87,6 +88,15 @@ pub enum Action { | ... | @@ -87,6 +88,15 @@ pub enum Action { |
| 87 | Move, | 88 | Move, |
| 88 | /// Moves it to a section, or into a folder, by catalog path. | 89 | /// Moves it to a section, or into a folder, by catalog path. |
| 89 | MoveTo(String), | 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 | CopyLink, | 100 | CopyLink, |
| 91 | NewPage, | 101 | NewPage, |
| 92 | NewSubpage, | 102 | NewSubpage, |
| ... | @@ -110,6 +120,8 @@ pub enum Action { | ... | @@ -110,6 +120,8 @@ pub enum Action { |
| 110 | /// Moves a notebook up the notebook list, or down. | 120 | /// Moves a notebook up the notebook list, or down. |
| 111 | Raise(bool), | 121 | Raise(bool), |
| 112 | Properties, | 122 | Properties, |
| 123 | /// Opens Password Protection on a section. | ||
| 124 | Password, | ||
| 113 | } | 125 | } |
| 114 | 126 | ||
| 115 | /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section | 127 | /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section |
| ... | @@ -259,7 +271,7 @@ impl State { | ... | @@ -259,7 +271,7 @@ impl State { |
| 259 | pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { | 271 | pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { |
| 260 | // An action, its label, whether it is disabled, and whether a rule starts its group. | 272 | // An action, its label, whether it is disabled, and whether a rule starts its group. |
| 261 | let item = |action: Action, text, disabled, separated| { | 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 | let item = Item { | 275 | let item = Item { |
| 264 | text, | 276 | text, |
| 265 | disabled, | 277 | disabled, |
| ... | @@ -271,6 +283,18 @@ impl State { | ... | @@ -271,6 +283,18 @@ impl State { |
| 271 | }; | 283 | }; |
| 272 | let nowhere = || self.destinations(target).is_empty(); | 284 | let nowhere = || self.destinations(target).is_empty(); |
| 273 | match target { | 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 | Target::Page { | 298 | Target::Page { |
| 275 | library, | 299 | library, |
| 276 | path, | 300 | path, |
| ... | @@ -321,16 +345,26 @@ impl State { | ... | @@ -321,16 +345,26 @@ impl State { |
| 321 | actions | 345 | actions |
| 322 | } | 346 | } |
| 323 | Target::Section { library, path } | Target::Group { library, path } => { | 347 | Target::Section { library, path } | Target::Group { library, path } => { |
| 324 | let mut actions = vec![ | 348 | let section = matches!(target, Target::Section { .. }); |
| 325 | item(Action::Rename, "Rename", false, false), | 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 | item(Action::Delete, "Delete", false, false), | 354 | item(Action::Delete, "Delete", false, false), |
| 327 | item(Action::Move, "Move", nowhere(), false), | 355 | item(Action::Move, "Move", nowhere(), false), |
| 328 | item(Action::NewSection, "New Section", false, true), | 356 | item(Action::NewSection, "New Section", false, true), |
| 329 | item(Action::NewGroup, "New Section Group", false, false), | 357 | item(Action::NewGroup, "New Section Group", false, false), |
| 330 | ]; | 358 | ]); |
| 331 | if matches!(target, Target::Section { .. }) { | 359 | if section { |
| 332 | actions.extend([ | 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 | item( | 368 | item( |
| 335 | Action::Reveal, | 369 | Action::Reveal, |
| 336 | platform::SHOW_FILE, | 370 | platform::SHOW_FILE, |
| ... | @@ -345,8 +379,15 @@ impl State { | ... | @@ -345,8 +379,15 @@ impl State { |
| 345 | Target::Notebook(library) => { | 379 | Target::Notebook(library) => { |
| 346 | let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); | 380 | let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); |
| 347 | let last = self.notebooks.len().saturating_sub(1); | 381 | let last = self.notebooks.len().saturating_sub(1); |
| 382 | let unread = self.unread_notebook(library); | ||
| 348 | vec![ | 383 | vec![ |
| 349 | item(Action::Rename, "Rename…", false, false), | 384 | item(Action::Rename, "Rename…", false, false), |
| 385 | item( | ||
| 386 | Action::SaveAs, | ||
| 387 | "Save As…", | ||
| 388 | library.catalog().is_none(), | ||
| 389 | false, | ||
| 390 | ), | ||
| 350 | item( | 391 | item( |
| 351 | Action::Sync, | 392 | Action::Sync, |
| 352 | "Sync This Notebook Now", | 393 | "Sync This Notebook Now", |
| ... | @@ -369,6 +410,12 @@ impl State { | ... | @@ -369,6 +410,12 @@ impl State { |
| 369 | false, | 410 | false, |
| 370 | false, | 411 | false, |
| 371 | ), | 412 | ), |
| 413 | item( | ||
| 414 | Action::MarkNotebookRead, | ||
| 415 | "Mark Notebook as Read", | ||
| 416 | !unread, | ||
| 417 | true, | ||
| 418 | ), | ||
| 372 | item( | 419 | item( |
| 373 | Action::Raise(true), | 420 | Action::Raise(true), |
| 374 | "Move Up", | 421 | "Move Up", |
| ... | @@ -388,6 +435,12 @@ impl State { | ... | @@ -388,6 +435,12 @@ impl State { |
| 388 | true, | 435 | true, |
| 389 | ), | 436 | ), |
| 390 | item(Action::Theme, "Theme…", false, false), | 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 | item( | 444 | item( |
| 392 | Action::Properties, | 445 | Action::Properties, |
| 393 | "Properties…", | 446 | "Properties…", |
| ... | @@ -416,13 +469,22 @@ impl State { | ... | @@ -416,13 +469,22 @@ impl State { |
| 416 | let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect(); | 469 | let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect(); |
| 417 | let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); | 470 | let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); |
| 418 | let places = id.child("move"); | 471 | let places = id.child("move"); |
| 472 | let copies = id.child("copy"); | ||
| 419 | let colors = id.child("colors"); | 473 | let colors = id.child("colors"); |
| 420 | ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { | 474 | ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { |
| 421 | Action::Move => Some(places), | 475 | Action::Move => Some(places), |
| 476 | Action::Copy => Some(copies), | ||
| 422 | Action::Colors => Some(colors), | 477 | Action::Colors => Some(colors), |
| 423 | _ => None, | 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 | let destinations = self.destinations(target); | 488 | let destinations = self.destinations(target); |
| 427 | let items: Vec<Item> = (destinations.iter()) | 489 | let items: Vec<Item> = (destinations.iter()) |
| 428 | .map(|place| Item { | 490 | .map(|place| Item { |
| ... | @@ -432,11 +494,9 @@ impl State { | ... | @@ -432,11 +494,9 @@ impl State { |
| 432 | ..Item::default() | 494 | ..Item::default() |
| 433 | }) | 495 | }) |
| 434 | .collect(); | 496 | .collect(); |
| 435 | ui::popup::menu(&mut self.ui, places, anchor, &items, None) | 497 | moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None) |
| 436 | .map(|index| Action::MoveTo(destinations[index].path.clone())) | 498 | .map(|index| chosen(destinations[index].path.clone()))); |
| 437 | } else { | 499 | } |
| 438 | None | ||
| 439 | }; | ||
| 440 | let colored = match target { | 500 | let colored = match target { |
| 441 | Target::Section { library, path } if self.ui.popup_open(colors) => { | 501 | Target::Section { library, path } if self.ui.popup_open(colors) => { |
| 442 | let current = (library.tabs(&folder(path)).into_iter()) | 502 | let current = (library.tabs(&folder(path)).into_iter()) |
| ... | @@ -474,6 +534,17 @@ impl State { | ... | @@ -474,6 +534,17 @@ impl State { |
| 474 | fn destinations(&self, target: &Target) -> Vec<Destination> { | 534 | fn destinations(&self, target: &Target) -> Vec<Destination> { |
| 475 | let theme = &self.ui.theme; | 535 | let theme = &self.ui.theme; |
| 476 | match target { | 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 | Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) | 548 | Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) |
| 478 | .filter(|tab| tab.path != *path) | 549 | .filter(|tab| tab.path != *path) |
| 479 | .map(|tab| Destination { | 550 | .map(|tab| Destination { |
| ... | @@ -536,6 +607,33 @@ impl State { | ... | @@ -536,6 +607,33 @@ impl State { |
| 536 | (Target::Section { library, path }, Action::Open) => { | 607 | (Target::Section { library, path }, Action::Open) => { |
| 537 | Some(Command::OpenSection(library, path)) | 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 | (Target::Notebook(library), Action::Open) => { | 637 | (Target::Notebook(library), Action::Open) => { |
| 540 | self.open_notebook(library.location.clone(), None); | 638 | self.open_notebook(library.location.clone(), None); |
| 541 | None | 639 | None |
| ... | @@ -665,7 +763,22 @@ impl State { | ... | @@ -665,7 +763,22 @@ impl State { |
| 665 | self.rename(crate::rename::Target::Page(space)); | 763 | self.rename(crate::rename::Target::Page(space)); |
| 666 | None | 764 | None |
| 667 | } | 765 | } |
| 766 | Action::Delete if self.in_recycle_bin() => { | ||
| 767 | Some(Command::Recycle(Request::Purge(vec![space]))) | ||
| 768 | } | ||
| 668 | Action::Delete => Some(Command::DeletePages(vec![space])), | 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 | Action::MoveTo(path) => Some(Command::MovePage { space, path }), | 782 | Action::MoveTo(path) => Some(Command::MovePage { space, path }), |
| 670 | Action::CopyLink => { | 783 | Action::CopyLink => { |
| 671 | let copied = self | 784 | let copied = self |
| ... | @@ -915,15 +1028,9 @@ fn new(action: &Action, folder: String) -> Option<Structure> { | ... | @@ -915,15 +1028,9 @@ fn new(action: &Action, folder: String) -> Option<Structure> { |
| 915 | /// `library`'s folders of sections, the notebook's first, then its section groups breadth | 1028 | /// `library`'s folders of sections, the notebook's first, then its section groups breadth |
| 916 | /// first, but for the recycle bin; none for a section opened on its own. | 1029 | /// first, but for the recycle bin; none for a section opened on its own. |
| 917 | pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> { | 1030 | pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> { |
| 918 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); | 1031 | library.catalog().map_or_else(Vec::new, |catalog| { |
| 919 | let mut at = 0; | 1032 | crate::library::folders(catalog, |group| !crate::library::recycle_bin(&group.path)) |
| 920 | while let Some(&folder) = folders.get(at) { | 1033 | }) |
| 921 | folders.extend( | ||
| 922 | (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)), | ||
| 923 | ); | ||
| 924 | at += 1; | ||
| 925 | } | ||
| 926 | folders | ||
| 927 | } | 1034 | } |
| 928 | 1035 | ||
| 929 | #[cfg(test)] | 1036 | #[cfg(test)] |
crates/snowbound/src/navigation.rs+1| ... | @@ -288,6 +288,7 @@ mod tests { | ... | @@ -288,6 +288,7 @@ mod tests { |
| 288 | identity: None, | 288 | identity: None, |
| 289 | created: None, | 289 | created: None, |
| 290 | margin_origin: [0.0; 2], | 290 | margin_origin: [0.0; 2], |
| 291 | rtl: false, | ||
| 291 | color: None, | 292 | color: None, |
| 292 | rule_lines: None, | 293 | rule_lines: None, |
| 293 | objects: Vec::new(), | 294 | objects: Vec::new(), |
crates/snowbound/src/options.rs+66| ... | @@ -125,6 +125,28 @@ const SECTIONS: &[Section] = &[ | ... | @@ -125,6 +125,28 @@ const SECTIONS: &[Section] = &[ |
| 125 | control: Control::Check(|options| &mut options.pen_pressure), | 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 | Section { | 152 | Section { |
| ... | @@ -210,6 +232,10 @@ pub struct Options { | ... | @@ -210,6 +232,10 @@ pub struct Options { |
| 210 | default_font: DefaultFont, | 232 | default_font: DefaultFont, |
| 211 | page_tabs_left: bool, | 233 | page_tabs_left: bool, |
| 212 | navigation_bar_left: bool, | 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 | pub(crate) keyboard: crate::keys::Keyboard, | 239 | pub(crate) keyboard: crate::keys::Keyboard, |
| 214 | } | 240 | } |
| 215 | 241 | ||
| ... | @@ -241,6 +267,10 @@ fn sizes() -> Id { | ... | @@ -241,6 +267,10 @@ fn sizes() -> Id { |
| 241 | id().child("sizes") | 267 | id().child("sizes") |
| 242 | } | 268 | } |
| 243 | 269 | ||
| 270 | fn lock_times() -> Id { | ||
| 271 | id().child("lock-times") | ||
| 272 | } | ||
| 273 | |||
| 244 | fn font_colors() -> Id { | 274 | fn font_colors() -> Id { |
| 245 | id().child("font-colors") | 275 | id().child("font-colors") |
| 246 | } | 276 | } |
| ... | @@ -298,6 +328,9 @@ impl State { | ... | @@ -298,6 +328,9 @@ impl State { |
| 298 | default_font: self.view.editor.default_font.clone(), | 328 | default_font: self.view.editor.default_font.clone(), |
| 299 | page_tabs_left: self.page_tabs_left, | 329 | page_tabs_left: self.page_tabs_left, |
| 300 | navigation_bar_left: !self.navigation_bar_right, | 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 | keyboard: crate::keys::Keyboard::new(), | 334 | keyboard: crate::keys::Keyboard::new(), |
| 302 | }); | 335 | }); |
| 303 | self.ui.open_popup(id()); | 336 | self.ui.open_popup(id()); |
| ... | @@ -600,6 +633,10 @@ impl State { | ... | @@ -600,6 +633,10 @@ impl State { |
| 600 | self.view.editor.default_font = options.default_font; | 633 | self.view.editor.default_font = options.default_font; |
| 601 | self.page_tabs_left = options.page_tabs_left; | 634 | self.page_tabs_left = options.page_tabs_left; |
| 602 | self.navigation_bar_right = !options.navigation_bar_left; | 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 | self.updates.set_automatic(options.automatic_updates); | 640 | self.updates.set_automatic(options.automatic_updates); |
| 604 | self.show_spelling(); | 641 | self.show_spelling(); |
| 605 | self.follow_color_scheme(); | 642 | self.follow_color_scheme(); |
| ... | @@ -679,6 +716,35 @@ fn appearance(state: &mut State, options: &mut Options) { | ... | @@ -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 | fn user_name_field(state: &mut State, options: &mut Options) { | 748 | fn user_name_field(state: &mut State, options: &mut Options) { |
| 683 | let ui = &mut state.ui; | 749 | let ui = &mut state.ui; |
| 684 | let theme = &ui.theme; | 750 | let theme = &ui.theme; |
crates/snowbound/src/pane.rs+1-1| ... | @@ -929,7 +929,7 @@ impl State { | ... | @@ -929,7 +929,7 @@ impl State { |
| 929 | /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. | 929 | /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. |
| 930 | fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> { | 930 | fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> { |
| 931 | let session = self.session.as_ref(); | 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 | // The open page with its subpages, or the page it is a subpage of with its others. | 933 | // The open page with its subpages, or the page it is a subpage of with its others. |
| 934 | let group: Vec<_> = session | 934 | let group: Vec<_> = session |
| 935 | .and_then(|session| { | 935 | .and_then(|session| { |
crates/snowbound/src/prefetch.rs+13| ... | @@ -38,6 +38,11 @@ impl<K: PartialEq, V> Recent<K, V> { | ... | @@ -38,6 +38,11 @@ impl<K: PartialEq, V> Recent<K, V> { |
| 38 | self.entries.iter().any(|(held, _)| held == key) | 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 | pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> { | 46 | pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> { |
| 42 | self.entries.iter_mut().map(|(_, value)| value) | 47 | self.entries.iter_mut().map(|(_, value)| value) |
| 43 | } | 48 | } |
| ... | @@ -143,6 +148,14 @@ impl Prefetch { | ... | @@ -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 | /// Whether `target` is new since it was last asked for, noting it. | 159 | /// Whether `target` is new since it was last asked for, noting it. |
| 147 | fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool { | 160 | fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool { |
| 148 | let new = !self.asked.contains(&target); | 161 | let new = !self.asked.contains(&target); |
crates/snowbound/src/print.rs+167-28| ... | @@ -146,6 +146,14 @@ impl State { | ... | @@ -146,6 +146,14 @@ impl State { |
| 146 | self.ui.open_popup(id()); | 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 | /// Builds the dialog while it is open; Print or Export goes on with its choices, which | 157 | /// Builds the dialog while it is open; Print or Export goes on with its choices, which |
| 150 | /// the next opening starts from. | 158 | /// the next opening starts from. |
| 151 | pub(crate) fn print_dialog(&mut self) { | 159 | pub(crate) fn print_dialog(&mut self) { |
| ... | @@ -247,22 +255,7 @@ impl State { | ... | @@ -247,22 +255,7 @@ impl State { |
| 247 | setup.footer = FOOTERS[at]; | 255 | setup.footer = FOOTERS[at]; |
| 248 | } | 256 | } |
| 249 | }); | 257 | }); |
| 250 | ui.open( | 258 | crate::buttons(ui); |
| 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 | ); | ||
| 266 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 259 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 267 | let action = if dialog.export { | 260 | let action = if dialog.export { |
| 268 | "Export…" | 261 | "Export…" |
| ... | @@ -349,6 +342,7 @@ impl State { | ... | @@ -349,6 +342,7 @@ impl State { |
| 349 | fit_width: setup.fit_width, | 342 | fit_width: setup.fit_width, |
| 350 | footer: setup.footer, | 343 | footer: setup.footer, |
| 351 | }; | 344 | }; |
| 345 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); | ||
| 352 | std::thread::spawn(move || { | 346 | std::thread::spawn(move || { |
| 353 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { | 347 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { |
| 354 | let mut open = Some(open); | 348 | let mut open = Some(open); |
| ... | @@ -359,6 +353,7 @@ impl State { | ... | @@ -359,6 +353,7 @@ impl State { |
| 359 | None => replica.page(space).map_err(Into::into), | 353 | None => replica.page(space).map_err(Into::into), |
| 360 | }) | 354 | }) |
| 361 | .collect::<Result<Vec<Page>, Box<dyn Error>>>()?; | 355 | .collect::<Result<Vec<Page>, Box<dyn Error>>>()?; |
| 356 | let this = if whole { printed(this) } else { this }; | ||
| 362 | let mut this = Some((open_tab.1.clone(), this)); | 357 | let mut this = Some((open_tab.1.clone(), this)); |
| 363 | let sections = if others.is_empty() { | 358 | let sections = if others.is_empty() { |
| 364 | this.into_iter().collect() | 359 | this.into_iter().collect() |
| ... | @@ -377,7 +372,7 @@ impl State { | ... | @@ -377,7 +372,7 @@ impl State { |
| 377 | .into_iter() | 372 | .into_iter() |
| 378 | .map(|(space, ..)| section.page(space)) | 373 | .map(|(space, ..)| section.page(space)) |
| 379 | .collect::<Result<Vec<_>, _>>()?; | 374 | .collect::<Result<Vec<_>, _>>()?; |
| 380 | Ok((name, pages)) | 375 | Ok((name, printed(pages))) |
| 381 | }) | 376 | }) |
| 382 | .collect::<Result<Vec<_>, Box<dyn Error>>>()? | 377 | .collect::<Result<Vec<_>, Box<dyn Error>>>()? |
| 383 | }; | 378 | }; |
| ... | @@ -419,18 +414,20 @@ fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> { | ... | @@ -419,18 +414,20 @@ fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> { |
| 419 | start..end | 414 | start..end |
| 420 | } | 415 | } |
| 421 | 416 | ||
| 422 | /// Every readable section of `library`, catalog path and name, a folder's sections before | 417 | /// Every readable section of `library` but the recycle bin's, catalog path and name, in the |
| 423 | /// its groups', as OneNote orders them. | 418 | /// order each folder's TOC lists its sections and groups, as OneNote's notebook PDF prints them. |
| 424 | fn notebook_tabs(library: &Library) -> Vec<(String, String)> { | 419 | fn notebook_tabs(library: &Library) -> Vec<(String, String)> { |
| 425 | fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { | 420 | fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { |
| 426 | tabs.extend( | 421 | let mut sections = library.tabs(&folder.path); |
| 427 | library | 422 | for path in &folder.order { |
| 428 | .tabs(&folder.path) | 423 | if let Some(at) = sections.iter().position(|tab| tab.path == *path) { |
| 429 | .into_iter() | 424 | let tab = sections.remove(at); |
| 430 | .map(|tab| (tab.path, tab.name)), | 425 | tabs.push((tab.path, tab.name)); |
| 431 | ); | 426 | } else if let Some(group) = (folder.groups.iter()) |
| 432 | for group in &folder.groups { | 427 | .find(|group| group.path == *path && !crate::library::recycle_bin(&group.path)) |
| 433 | walk(library, group, tabs); | 428 | { |
| 429 | walk(library, group, tabs); | ||
| 430 | } | ||
| 434 | } | 431 | } |
| 435 | } | 432 | } |
| 436 | let mut tabs = Vec::new(); | 433 | let mut tabs = Vec::new(); |
| ... | @@ -441,8 +438,17 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { | ... | @@ -441,8 +438,17 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { |
| 441 | tabs | 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 | /// `title` as a file name: without the characters Windows and macOS refuse in one. | 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 | let name: String = title | 452 | let name: String = title |
| 447 | .chars() | 453 | .chars() |
| 448 | .map(|c| match c { | 454 | .map(|c| match c { |
| ... | @@ -486,4 +492,137 @@ mod tests { | ... | @@ -486,4 +492,137 @@ mod tests { |
| 486 | assert_eq!(group(&levels, 3), 3..4); | 492 | assert_eq!(group(&levels, 3), 3..4); |
| 487 | assert_eq!(group(&levels, 5), 4..6); | 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,27 +151,11 @@ impl State { |
| 151 | } | 151 | } |
| 152 | label(ui, "Location:"); | 152 | label(ui, "Location:"); |
| 153 | dim(ui, "location", &dialog.library.location); | 153 | dim(ui, "location", &dialog.library.location); |
| 154 | let folder = dialog.library.folder().map(std::path::Path::to_owned); | ||
| 155 | let reveal = commands::command(commands::Id::ShowNotebook).title; | 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 | crate::platform::reveal(&dialog.library.location); | 156 | crate::platform::reveal(&dialog.library.location); |
| 158 | } | 157 | } |
| 159 | ui.open( | 158 | crate::buttons(ui); |
| 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 | ); | ||
| 175 | let ok = ui::button(ui, "ok", "OK").clicked || entered; | 159 | let ok = ui::button(ui, "ok", "OK").clicked || entered; |
| 176 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 160 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 177 | ui.close(); | 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,8 +81,7 @@ impl State { |
| 81 | let name = match &target { | 81 | let name = match &target { |
| 82 | Target::Entry { path, in_tab, .. } => { | 82 | Target::Entry { path, in_tab, .. } => { |
| 83 | self.sidebar |= !in_tab; | 83 | self.sidebar |= !in_tab; |
| 84 | let name = path.rsplit('/').next().unwrap_or_default(); | 84 | crate::library::entry_name(path).to_owned() |
| 85 | name.strip_suffix(".one").unwrap_or(name).to_owned() | ||
| 86 | } | 85 | } |
| 87 | Target::Page(space) => self | 86 | Target::Page(space) => self |
| 88 | .session | 87 | .session |
| ... | @@ -107,8 +106,7 @@ impl State { | ... | @@ -107,8 +106,7 @@ impl State { |
| 107 | } | 106 | } |
| 108 | match target { | 107 | match target { |
| 109 | Target::Entry { library, path, .. } => { | 108 | Target::Entry { library, path, .. } => { |
| 110 | let old = path.rsplit('/').next().unwrap_or_default(); | 109 | if name != crate::library::entry_name(&path) { |
| 111 | if name != old.strip_suffix(".one").unwrap_or(old) { | ||
| 112 | self.commands.push(Command::Structure( | 110 | self.commands.push(Command::Structure( |
| 113 | library, | 111 | library, |
| 114 | Structure::Rename { path, name }, | 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,29 +272,21 @@ fn run( |
| 272 | fn sections(library: &Library) -> Vec<String> { | 272 | fn sections(library: &Library) -> Vec<String> { |
| 273 | match &library.notebook { | 273 | match &library.notebook { |
| 274 | Ok(Some(notebook)) => { | 274 | Ok(Some(notebook)) => { |
| 275 | let mut paths = Vec::new(); | 275 | let enter = |
| 276 | let mut folders = vec![notebook.catalog()]; | 276 | |group: &notebook::discover::Folder| !crate::library::recycle_bin(&group.path); |
| 277 | while let Some(folder) = folders.pop() { | 277 | crate::library::folders(notebook.catalog(), enter) |
| 278 | paths.extend( | 278 | .into_iter() |
| 279 | folder | 279 | .flat_map(|folder| &folder.sections) |
| 280 | .sections | 280 | .filter(|section| match section.state { |
| 281 | .iter() | 281 | notebook::discover::SectionState::Readable { .. } => true, |
| 282 | .filter(|section| { | 282 | // OneNote searches a protected section only while it is unlocked. |
| 283 | matches!( | 283 | notebook::discover::SectionState::Locked => { |
| 284 | section.state, | 284 | library.unlocked(&section.path).is_some() |
| 285 | notebook::discover::SectionState::Readable { .. } | 285 | } |
| 286 | ) | 286 | _ => false, |
| 287 | }) | 287 | }) |
| 288 | .map(|section| section.path.clone()), | 288 | .map(|section| section.path.clone()) |
| 289 | ); | 289 | .collect() |
| 290 | folders.extend( | ||
| 291 | folder | ||
| 292 | .groups | ||
| 293 | .iter() | ||
| 294 | .filter(|group| !crate::library::recycle_bin(&group.path)), | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | paths | ||
| 298 | } | 290 | } |
| 299 | Ok(None) => vec![library.location.clone()], | 291 | Ok(None) => vec![library.location.clone()], |
| 300 | Err(_) => Vec::new(), | 292 | Err(_) => Vec::new(), |
| ... | @@ -339,7 +331,10 @@ fn sync( | ... | @@ -339,7 +331,10 @@ fn sync( |
| 339 | Some(notebook) => notebook.read_section(&path)?, | 331 | Some(notebook) => notebook.read_section(&path)?, |
| 340 | None => onestore::read_file(&file)?, | 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 | let modified = |space: ExGuid| { | 338 | let modified = |space: ExGuid| { |
| 344 | stored | 339 | stored |
| 345 | .iter() | 340 | .iter() |
| ... | @@ -485,12 +480,10 @@ impl State { | ... | @@ -485,12 +480,10 @@ impl State { |
| 485 | /// Brings the index to the open notebooks and section when they changed, and when | 480 | /// Brings the index to the open notebooks and section when they changed, and when |
| 486 | /// `always` or sections `changed` names, by key, to the section files changed since. | 481 | /// `always` or sections `changed` names, by key, to the section files changed since. |
| 487 | pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) { | 482 | pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) { |
| 488 | let open = self.session.as_ref().map(|session| { | 483 | let open = self |
| 489 | ( | 484 | .session |
| 490 | session.library.key(&session.tabs[session.tab].path), | 485 | .as_ref() |
| 491 | Arc::downgrade(session.section.replica()), | 486 | .map(|session| (session.key(), Arc::downgrade(session.section.replica()))); |
| 492 | ) | ||
| 493 | }); | ||
| 494 | let identity: Vec<usize> = self | 487 | let identity: Vec<usize> = self |
| 495 | .notebooks | 488 | .notebooks |
| 496 | .iter() | 489 | .iter() |
| ... | @@ -511,11 +504,8 @@ impl State { | ... | @@ -511,11 +504,8 @@ impl State { |
| 511 | /// Tells the index an edit changed page `space` of the open section. | 504 | /// Tells the index an edit changed page `space` of the open section. |
| 512 | pub(crate) fn edited(&self, spaces: Vec<ExGuid>) { | 505 | pub(crate) fn edited(&self, spaces: Vec<ExGuid>) { |
| 513 | if let Some(session) = &self.session { | 506 | if let Some(session) = &self.session { |
| 514 | self.search.changed( | 507 | self.search |
| 515 | session.library.key(&session.tabs[session.tab].path), | 508 | .changed(session.key(), session.section.replica(), spaces); |
| 516 | session.section.replica(), | ||
| 517 | spaces, | ||
| 518 | ); | ||
| 519 | } | 509 | } |
| 520 | } | 510 | } |
| 521 | 511 | ||
| ... | @@ -544,7 +534,7 @@ impl State { | ... | @@ -544,7 +534,7 @@ impl State { |
| 544 | let open = self | 534 | let open = self |
| 545 | .session | 535 | .session |
| 546 | .as_ref() | 536 | .as_ref() |
| 547 | .map(|session| session.library.key(&session.tabs[session.tab].path)) | 537 | .map(crate::Session::key) |
| 548 | .unwrap_or_default(); | 538 | .unwrap_or_default(); |
| 549 | let wanted = (query.clone(), self.search.scope, version, open); | 539 | let wanted = (query.clone(), self.search.scope, version, open); |
| 550 | if self.search.found_for.as_ref() == Some(&wanted) { | 540 | if self.search.found_for.as_ref() == Some(&wanted) { |
| ... | @@ -673,12 +663,10 @@ impl State { | ... | @@ -673,12 +663,10 @@ impl State { |
| 673 | ) -> Result<(), Box<dyn Error>> { | 663 | ) -> Result<(), Box<dyn Error>> { |
| 674 | let (location, path) = section.split_once('\n').unwrap_or_default(); | 664 | let (location, path) = section.split_once('\n').unwrap_or_default(); |
| 675 | self.search.reveal = Some((space, paragraph)); | 665 | self.search.reveal = Some((space, paragraph)); |
| 676 | let open = self.session.as_ref().map(|session| { | 666 | let open = self |
| 677 | ( | 667 | .session |
| 678 | session.library.key(&session.tabs[session.tab].path), | 668 | .as_ref() |
| 679 | session.space, | 669 | .map(|session| (session.key(), session.space)); |
| 680 | ) | ||
| 681 | }); | ||
| 682 | match open { | 670 | match open { |
| 683 | Some((key, shown)) if key == section && shown == space => self.refind(true)?, | 671 | Some((key, shown)) if key == section && shown == space => self.refind(true)?, |
| 684 | Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)), | 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,6 +48,27 @@ pub struct Settings { |
| 48 | pub keys: std::collections::BTreeMap<String, Vec<String>>, | 48 | pub keys: std::collections::BTreeMap<String, Vec<String>>, |
| 49 | /// Pages shown lately, latest first, which the palette lists first. | 49 | /// Pages shown lately, latest first, which the palette lists first. |
| 50 | pub recent: Vec<crate::navigation::Place>, | 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 | /// What the toolbar's buttons apply from their menus' last picks. | 74 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | @@ -166,6 +187,7 @@ impl crate::State { | ... | @@ -166,6 +187,7 @@ impl crate::State { |
| 166 | servers: self.servers.clone(), | 187 | servers: self.servers.clone(), |
| 167 | keys: crate::commands::Keymap::current().saved(), | 188 | keys: crate::commands::Keymap::current().saved(), |
| 168 | recent: self.trail.recent.clone(), | 189 | recent: self.trail.recent.clone(), |
| 190 | passwords: self.passwords, | ||
| 169 | }; | 191 | }; |
| 170 | if let Err(error) = settings.save(path) { | 192 | if let Err(error) = settings.save(path) { |
| 171 | eprintln!("Cannot save the settings in {}: {error}", path.display()); | 193 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | @@ -244,6 +266,10 @@ mod tests { | ... | @@ -244,6 +266,10 @@ mod tests { |
| 244 | n: 1, | 266 | n: 1, |
| 245 | }, | 267 | }, |
| 246 | }], | 268 | }], |
| 269 | passwords: Passwords { | ||
| 270 | lock_after: None, | ||
| 271 | lock_on_leave: true, | ||
| 272 | }, | ||
| 247 | }; | 273 | }; |
| 248 | settings.save(&path).unwrap(); | 274 | settings.save(&path).unwrap(); |
| 249 | assert_eq!(Settings::load(&path), settings); | 275 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+38-20| ... | @@ -28,7 +28,7 @@ pub enum Action { | ... | @@ -28,7 +28,7 @@ pub enum Action { |
| 28 | notebook: usize, | 28 | notebook: usize, |
| 29 | path: String, | 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 | Fold(String), | 32 | Fold(String), |
| 33 | NewNotebook, | 33 | NewNotebook, |
| 34 | NewICloudNotebook, | 34 | NewICloudNotebook, |
| ... | @@ -45,11 +45,6 @@ pub enum Action { | ... | @@ -45,11 +45,6 @@ pub enum Action { |
| 45 | Unavailable(String, String), | 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 | /// What the tree's rows read and what they were asked. | 48 | /// What the tree's rows read and what they were asked. |
| 54 | pub struct Tree<'a> { | 49 | pub struct Tree<'a> { |
| 55 | theme: &'a Theme, | 50 | theme: &'a Theme, |
| ... | @@ -68,6 +63,8 @@ pub struct Tree<'a> { | ... | @@ -68,6 +63,8 @@ pub struct Tree<'a> { |
| 68 | ghost: Option<Ghost>, | 63 | ghost: Option<Ghost>, |
| 69 | /// Where the gap the dragged row would land in stands. | 64 | /// Where the gap the dragged row would land in stands. |
| 70 | gap: Option<[f32; 4]>, | 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 | /// Where a dragged row would land: before a row of a folder or at its end, or into a group. | 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,6 +82,7 @@ struct Ghost { |
| 85 | depth: u32, | 82 | depth: u32, |
| 86 | dim: bool, | 83 | dim: bool, |
| 87 | selected: Option<[f32; 4]>, | 84 | selected: Option<[f32; 4]>, |
| 85 | bold: bool, | ||
| 88 | } | 86 | } |
| 89 | 87 | ||
| 90 | impl<'a> Tree<'a> { | 88 | impl<'a> Tree<'a> { |
| ... | @@ -105,6 +103,7 @@ impl<'a> Tree<'a> { | ... | @@ -105,6 +103,7 @@ impl<'a> Tree<'a> { |
| 105 | lifted: None, | 103 | lifted: None, |
| 106 | ghost: None, | 104 | ghost: None, |
| 107 | gap: None, | 105 | gap: None, |
| 106 | unread: HashSet::new(), | ||
| 108 | } | 107 | } |
| 109 | } | 108 | } |
| 110 | } | 109 | } |
| ... | @@ -137,14 +136,13 @@ pub fn header() -> Id { | ... | @@ -137,14 +136,13 @@ pub fn header() -> Id { |
| 137 | Id::ROOT.child("sidebar header") | 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 | /// with `rows` the tree of `notebooks` with the open section marked. The notebook button | 140 | /// with `rows` the tree of `notebooks` with the open section marked. The notebook button |
| 142 | /// floats over the header's end at the window's edge, the `right` one or the left. | 141 | /// floats over the header's end at the window's edge, the `right` one or the left. |
| 143 | fn sidebar( | 142 | fn sidebar( |
| 144 | ui: &mut Ui, | 143 | ui: &mut Ui, |
| 145 | tree: &mut Tree, | 144 | tree: &mut Tree, |
| 146 | notebooks: &[Arc<Library>], | 145 | notebooks: &[Arc<Library>], |
| 147 | header: f32, | ||
| 148 | drags: bool, | 146 | drags: bool, |
| 149 | rows: bool, | 147 | rows: bool, |
| 150 | right: bool, | 148 | right: bool, |
| ... | @@ -158,8 +156,8 @@ fn sidebar( | ... | @@ -158,8 +156,8 @@ fn sidebar( |
| 158 | } else { | 156 | } else { |
| 159 | Flags::default() | 157 | Flags::default() |
| 160 | }, | 158 | }, |
| 161 | size: [fill(), px(header)], | 159 | size: [fill(), px(RAIL)], |
| 162 | pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0 + DROP], | 160 | pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP], |
| 163 | gap: 6.0, | 161 | gap: 6.0, |
| 164 | ..Spec::default() | 162 | ..Spec::default() |
| 165 | }, | 163 | }, |
| ... | @@ -204,7 +202,7 @@ fn sidebar( | ... | @@ -204,7 +202,7 @@ fn sidebar( |
| 204 | }, | 202 | }, |
| 205 | ); | 203 | ); |
| 206 | for (index, library) in notebooks.iter().enumerate() { | 204 | for (index, library) in notebooks.iter().enumerate() { |
| 207 | let key = fold_key(library, ""); | 205 | let key = library.key(""); |
| 208 | let unfolded = !folded.contains(&key); | 206 | let unfolded = !folded.contains(&key); |
| 209 | let (row, fold) = tree_row( | 207 | let (row, fold) = tree_row( |
| 210 | ui, | 208 | ui, |
| ... | @@ -222,6 +220,7 @@ fn sidebar( | ... | @@ -222,6 +220,7 @@ fn sidebar( |
| 222 | fold: Some(unfolded), | 220 | fold: Some(unfolded), |
| 223 | selected: None, | 221 | selected: None, |
| 224 | renamed: false, | 222 | renamed: false, |
| 223 | bold: tree.unread.contains(&key), | ||
| 225 | }, | 224 | }, |
| 226 | ); | 225 | ); |
| 227 | let unsigned = library.notebook.is_err() | 226 | let unsigned = library.notebook.is_err() |
| ... | @@ -281,6 +280,7 @@ fn sidebar( | ... | @@ -281,6 +280,7 @@ fn sidebar( |
| 281 | fold: None, | 280 | fold: None, |
| 282 | selected: None, | 281 | selected: None, |
| 283 | renamed: false, | 282 | renamed: false, |
| 283 | bold: false, | ||
| 284 | }, | 284 | }, |
| 285 | ); | 285 | ); |
| 286 | if row.clicked { | 286 | if row.clicked { |
| ... | @@ -315,6 +315,12 @@ fn folder( | ... | @@ -315,6 +315,12 @@ fn folder( |
| 315 | *color, | 315 | *color, |
| 316 | true, | 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 | crate::library::section_name(&section.path, &None), | 325 | crate::library::section_name(&section.path, &None), |
| 320 | None, | 326 | None, |
| ... | @@ -332,6 +338,7 @@ fn folder( | ... | @@ -332,6 +338,7 @@ fn folder( |
| 332 | fold: None, | 338 | fold: None, |
| 333 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), | 339 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), |
| 334 | renamed: renaming, | 340 | renamed: renaming, |
| 341 | bold: tree.unread.contains(&library.key(&section.path)), | ||
| 335 | }; | 342 | }; |
| 336 | if lifts(tree, notebook, &section.path, &row) { | 343 | if lifts(tree, notebook, &section.path, &row) { |
| 337 | continue; | 344 | continue; |
| ... | @@ -357,7 +364,7 @@ fn folder( | ... | @@ -357,7 +364,7 @@ fn folder( |
| 357 | if crate::library::recycle_bin(&group.path) { | 364 | if crate::library::recycle_bin(&group.path) { |
| 358 | continue; | 365 | continue; |
| 359 | } | 366 | } |
| 360 | let key = fold_key(library, &group.path); | 367 | let key = library.key(&group.path); |
| 361 | let unfolded = !tree.folded.contains(&key); | 368 | let unfolded = !tree.folded.contains(&key); |
| 362 | let name = group.path.rsplit('/').next().unwrap_or_default(); | 369 | let name = group.path.rsplit('/').next().unwrap_or_default(); |
| 363 | gap(ui, tree, &folder.path, Some(&group.path)); | 370 | gap(ui, tree, &folder.path, Some(&group.path)); |
| ... | @@ -371,6 +378,7 @@ fn folder( | ... | @@ -371,6 +378,7 @@ fn folder( |
| 371 | fold: Some(unfolded), | 378 | fold: Some(unfolded), |
| 372 | selected: None, | 379 | selected: None, |
| 373 | renamed: renaming, | 380 | renamed: renaming, |
| 381 | bold: tree.unread.contains(&key), | ||
| 374 | }; | 382 | }; |
| 375 | // A dragged group's rows fold away beneath it. | 383 | // A dragged group's rows fold away beneath it. |
| 376 | let lifted = lifts(tree, notebook, &group.path, &row); | 384 | let lifted = lifts(tree, notebook, &group.path, &row); |
| ... | @@ -425,6 +433,7 @@ fn folder( | ... | @@ -425,6 +433,7 @@ fn folder( |
| 425 | fold: None, | 433 | fold: None, |
| 426 | selected: None, | 434 | selected: None, |
| 427 | renamed: false, | 435 | renamed: false, |
| 436 | bold: false, | ||
| 428 | }, | 437 | }, |
| 429 | ); | 438 | ); |
| 430 | if row.clicked { | 439 | if row.clicked { |
| ... | @@ -499,6 +508,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { | ... | @@ -499,6 +508,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { |
| 499 | depth: row.depth, | 508 | depth: row.depth, |
| 500 | dim: row.dim, | 509 | dim: row.dim, |
| 501 | selected: row.selected, | 510 | selected: row.selected, |
| 511 | bold: row.bold, | ||
| 502 | }); | 512 | }); |
| 503 | } | 513 | } |
| 504 | lifted | 514 | lifted |
| ... | @@ -594,6 +604,8 @@ struct Row<'a> { | ... | @@ -594,6 +604,8 @@ struct Row<'a> { |
| 594 | selected: Option<[f32; 4]>, | 604 | selected: Option<[f32; 4]>, |
| 595 | /// The row is being renamed: its label is the tree's rename field. | 605 | /// The row is being renamed: its label is the tree's rename field. |
| 596 | renamed: bool, | 606 | renamed: bool, |
| 607 | /// It holds unread pages, which OneNote sets bold. | ||
| 608 | bold: bool, | ||
| 597 | } | 609 | } |
| 598 | 610 | ||
| 599 | /// One row of the tree: its signal, and whether its fold arrow was clicked. | 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,6 +665,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { |
| 653 | Spec { | 665 | Spec { |
| 654 | size: [fill(), px(ROW)], | 666 | size: [fill(), px(ROW)], |
| 655 | text: Some(row.label), | 667 | text: Some(row.label), |
| 668 | bold: row.bold, | ||
| 656 | color: Some(color), | 669 | color: Some(color), |
| 657 | ..Spec::default() | 670 | ..Spec::default() |
| 658 | }, | 671 | }, |
| ... | @@ -729,15 +742,22 @@ impl crate::State { | ... | @@ -729,15 +742,22 @@ impl crate::State { |
| 729 | }, | 742 | }, |
| 730 | ); | 743 | ); |
| 731 | crate::name(&mut self.ui, rows_id, "Notebooks"); | 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 | let index = self | 751 | let index = self |
| 734 | .notebooks | 752 | .notebooks |
| 735 | .iter() | 753 | .iter() |
| 736 | .position(|library| Arc::ptr_eq(library, &session.library))?; | 754 | .position(|library| Arc::ptr_eq(library, shown))?; |
| 737 | Some((index, session.tabs[session.tab].path.as_str())) | 755 | Some((index, path)) |
| 738 | }); | 756 | }); |
| 739 | let drags = self.chrome_drags(); | 757 | let drags = self.chrome_drags(); |
| 758 | let unread = self.unread_keys(); | ||
| 740 | let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); | 759 | let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); |
| 760 | tree.unread = unread; | ||
| 741 | tree.lifted = self | 761 | tree.lifted = self |
| 742 | .drag | 762 | .drag |
| 743 | .as_ref() | 763 | .as_ref() |
| ... | @@ -750,7 +770,6 @@ impl crate::State { | ... | @@ -750,7 +770,6 @@ impl crate::State { |
| 750 | &mut self.ui, | 770 | &mut self.ui, |
| 751 | &mut tree, | 771 | &mut tree, |
| 752 | &self.notebooks, | 772 | &self.notebooks, |
| 753 | crate::TAB_ROW, | ||
| 754 | drags, | 773 | drags, |
| 755 | width > 0.5, | 774 | width > 0.5, |
| 756 | self.navigation_bar_right, | 775 | self.navigation_bar_right, |
| ... | @@ -800,9 +819,6 @@ impl crate::State { | ... | @@ -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 | /// The notebook button, floating at the body's corner over the section tabs' row, `height` | 822 | /// The notebook button, floating at the body's corner over the section tabs' row, `height` |
| 807 | /// tall as it eases, or over the sidebar's header while that is open. | 823 | /// tall as it eases, or over the sidebar's header while that is open. |
| 808 | pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { | 824 | pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { |
| ... | @@ -903,6 +919,7 @@ impl crate::State { | ... | @@ -903,6 +919,7 @@ impl crate::State { |
| 903 | fold: None, | 919 | fold: None, |
| 904 | selected: ghost.selected, | 920 | selected: ghost.selected, |
| 905 | renamed: false, | 921 | renamed: false, |
| 922 | bold: ghost.bold, | ||
| 906 | }, | 923 | }, |
| 907 | ); | 924 | ); |
| 908 | self.ui.close(); | 925 | self.ui.close(); |
| ... | @@ -1049,9 +1066,10 @@ impl crate::State { | ... | @@ -1049,9 +1066,10 @@ impl crate::State { |
| 1049 | self.commands | 1066 | self.commands |
| 1050 | .push(crate::Command::OpenSection(library, path)); | 1067 | .push(crate::Command::OpenSection(library, path)); |
| 1051 | } | 1068 | } |
| 1069 | crate::library::Located::Package(package) => self.open_unpack(&package), | ||
| 1052 | crate::library::Located::Nothing => crate::platform::alert( | 1070 | crate::library::Located::Nothing => crate::platform::alert( |
| 1053 | "Couldn't open", | 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,6 +1,7 @@ |
| 1 | //! Spelling through Enchant, loaded at run time with whichever dictionaries its providers | 1 | //! Spelling through Enchant, loaded at run time with whichever dictionaries its providers |
| 2 | //! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked. | 2 | //! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked. |
| 3 | 3 | ||
| 4 | use crate::platform::symbol; | ||
| 4 | use canvas::spelling::{Dictionary, pick}; | 5 | use canvas::spelling::{Dictionary, pick}; |
| 5 | use std::collections::HashMap; | 6 | use std::collections::HashMap; |
| 6 | use std::ffi::{CStr, CString, c_char, c_int, c_void}; | 7 | use std::ffi::{CStr, CString, c_char, c_int, c_void}; |
| ... | @@ -98,12 +99,6 @@ impl Dictionary for Checker { | ... | @@ -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 | unsafe extern "C" fn describe( | 102 | unsafe extern "C" fn describe( |
| 108 | tag: *const c_char, | 103 | tag: *const c_char, |
| 109 | _: *const c_char, | 104 | _: *const c_char, |
crates/snowbound/src/surface_gl.rs+11-11| ... | @@ -92,36 +92,36 @@ impl Surface { | ... | @@ -92,36 +92,36 @@ impl Surface { |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | /// The window's next frame, or none to skip this one. | 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 | if self.size.contains(&0) { | 96 | if self.size.contains(&0) { |
| 97 | return Ok(None); | 97 | return Ok(None); |
| 98 | } | 98 | } |
| 99 | let target = match self.target.take() { | 99 | let target = match self.target.take() { |
| 100 | Some(target) if target.size() == self.size => target, | 100 | Some(target) if target.size() == self.size => target, |
| 101 | _ => Target::new(self.size)?, | 101 | _ => renderer.target(self.size)?, |
| 102 | }; | 102 | }; |
| 103 | Ok(Some(Frame { target })) | 103 | Ok(Some(Frame { target })) |
| 104 | } | 104 | } |
| 105 | 105 | ||
| 106 | pub fn present(&mut self, renderer: &Renderer, frame: Frame) { | 106 | pub fn present(&mut self, renderer: &Renderer, frame: Frame) { |
| 107 | if self.translucent { | 107 | renderer.present(&frame.target, self.translucent); |
| 108 | renderer.present_translucent(&frame.target); | ||
| 109 | } else { | ||
| 110 | frame.target.present(); | ||
| 111 | } | ||
| 112 | let _: () = unsafe { msg_send![&self.context, flushBuffer] }; | 108 | let _: () = unsafe { msg_send![&self.context, flushBuffer] }; |
| 113 | self.target = Some(frame.target); | 109 | self.target = Some(frame.target); |
| 114 | } | 110 | } |
| 115 | 111 | ||
| 116 | /// A target the size of the window's frames that `read` reads back. | 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 | Ok(Frame { | 114 | Ok(Frame { |
| 119 | target: Target::new(self.size)?, | 115 | target: renderer.target(self.size)?, |
| 120 | }) | 116 | }) |
| 121 | } | 117 | } |
| 122 | 118 | ||
| 123 | /// The offscreen target's sRGB RGBA rows, top first. | 119 | /// The offscreen target's sRGB RGBA rows, top first. |
| 124 | pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> { | 120 | pub fn read( |
| 125 | Ok(offscreen.target.read_pixels()) | 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,11 +1,13 @@ |
| 1 | //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and | 1 | //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise |
| 2 | //! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB | 2 | //! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL |
| 3 | //! target, copied to the window's back buffer and swapped. | 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 | #[path = "surface.rs"] | 7 | #[path = "surface.rs"] |
| 6 | mod webgpu; | 8 | mod webgpu; |
| 7 | 9 | ||
| 8 | use draw::{GlTarget, Renderer, Target}; | 10 | use draw::{Renderer, Target}; |
| 9 | use std::{error::Error, sync::Arc}; | 11 | use std::{error::Error, sync::Arc}; |
| 10 | use windows_sys::Win32::{ | 12 | use windows_sys::Win32::{ |
| 11 | Foundation::HWND, | 13 | Foundation::HWND, |
| ... | @@ -27,13 +29,14 @@ pub struct Surface { | ... | @@ -27,13 +29,14 @@ pub struct Surface { |
| 27 | 29 | ||
| 28 | enum Backend { | 30 | enum Backend { |
| 29 | Wgpu(Box<webgpu::Surface>), | 31 | Wgpu(Box<webgpu::Surface>), |
| 30 | Gl { | 32 | /// Direct3D 11 or OpenGL, which present the renderer's own targets. |
| 31 | /// The window's device context, which the context draws to. | 33 | Native { |
| 32 | device: HDC, | 34 | /// For OpenGL, the window's device context, which the context draws to. |
| 35 | opengl: Option<HDC>, | ||
| 33 | /// Frames leave the desktop's glass showing through their transparent pixels. | 36 | /// Frames leave the desktop's glass showing through their transparent pixels. |
| 34 | translucent: bool, | 37 | translucent: bool, |
| 35 | /// The frame target, kept between frames. | 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,22 +60,42 @@ impl Surface { |
| 57 | backdrop: bool, | 60 | backdrop: bool, |
| 58 | ) -> Result<(Self, Renderer), Box<dyn Error>> { | 61 | ) -> Result<(Self, Renderer), Box<dyn Error>> { |
| 59 | let forced = std::env::var("SNOWBOUND_RENDERER").ok(); | 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 | match webgpu::Surface::new(window.clone(), backdrop).await { | 75 | match webgpu::Surface::new(window.clone(), backdrop).await { |
| 62 | Ok((surface, renderer)) => { | 76 | Ok((surface, renderer)) => { |
| 63 | let backend = Backend::Wgpu(Box::new(surface)); | 77 | let backend = Backend::Wgpu(Box::new(surface)); |
| 64 | let size = window.inner_size(); | ||
| 65 | let size = [size.width, size.height]; | ||
| 66 | return Ok((Self { size, backend }, renderer)); | 78 | return Ok((Self { size, backend }, renderer)); |
| 67 | } | 79 | } |
| 68 | Err(error) if forced.is_some() => return Err(error), | 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 | let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { | 84 | let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { |
| 73 | unreachable!() | 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 | let request = |alpha| gl::PIXELFORMATDESCRIPTOR { | 99 | let request = |alpha| gl::PIXELFORMATDESCRIPTOR { |
| 77 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, | 100 | nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16, |
| 78 | nVersion: 1, | 101 | nVersion: 1, |
| ... | @@ -147,25 +170,14 @@ impl Surface { | ... | @@ -147,25 +170,14 @@ impl Surface { |
| 147 | the driver from your graphics card's maker. ({error})" | 170 | the driver from your graphics card's maker. ({error})" |
| 148 | ) | 171 | ) |
| 149 | })?; | 172 | })?; |
| 150 | let size = window.inner_size(); | 173 | native(Some(device), translucent, renderer) |
| 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 | )) | ||
| 162 | } | 174 | } |
| 163 | 175 | ||
| 164 | /// Whether frames leave the system's backdrop showing. | 176 | /// Whether frames leave the system's backdrop showing. |
| 165 | pub fn translucent(&self) -> bool { | 177 | pub fn translucent(&self) -> bool { |
| 166 | match &self.backend { | 178 | match &self.backend { |
| 167 | Backend::Wgpu(surface) => surface.translucent(), | 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,13 +201,13 @@ impl Surface { |
| 189 | texture: Some((frame.texture, frame.reconfigure)), | 201 | texture: Some((frame.texture, frame.reconfigure)), |
| 190 | })) | 202 | })) |
| 191 | } | 203 | } |
| 192 | Backend::Gl { target, .. } => { | 204 | Backend::Native { target, .. } => { |
| 193 | let target = match target.take() { | 205 | let target = match target.take() { |
| 194 | Some(target) if target.size() == self.size => target, | 206 | Some(target) if target.size() == self.size => target, |
| 195 | _ => GlTarget::new(self.size)?, | 207 | _ => renderer.target(self.size)?, |
| 196 | }; | 208 | }; |
| 197 | Ok(Some(Frame { | 209 | Ok(Some(Frame { |
| 198 | target: target.into(), | 210 | target, |
| 199 | texture: None, | 211 | texture: None, |
| 200 | })) | 212 | })) |
| 201 | } | 213 | } |
| ... | @@ -213,20 +225,18 @@ impl Surface { | ... | @@ -213,20 +225,18 @@ impl Surface { |
| 213 | surface.present(renderer, frame); | 225 | surface.present(renderer, frame); |
| 214 | } | 226 | } |
| 215 | ( | 227 | ( |
| 216 | Backend::Gl { | 228 | Backend::Native { |
| 217 | device, | 229 | opengl, |
| 218 | translucent, | 230 | translucent, |
| 219 | target: kept, | 231 | target: kept, |
| 220 | }, | 232 | }, |
| 221 | Target::Gl(target), | 233 | target, |
| 222 | None, | 234 | None, |
| 223 | ) => { | 235 | ) => { |
| 224 | if *translucent { | 236 | renderer.present(&target, *translucent); |
| 225 | renderer.present_translucent(&target); | 237 | if let Some(device) = opengl { |
| 226 | } else { | 238 | unsafe { gl::SwapBuffers(*device) }; |
| 227 | target.present(); | ||
| 228 | } | 239 | } |
| 229 | unsafe { gl::SwapBuffers(*device) }; | ||
| 230 | *kept = Some(target); | 240 | *kept = Some(target); |
| 231 | } | 241 | } |
| 232 | _ => unreachable!("Frames come from the surface's own backend"), | 242 | _ => unreachable!("Frames come from the surface's own backend"), |
| ... | @@ -244,8 +254,8 @@ impl Surface { | ... | @@ -244,8 +254,8 @@ impl Surface { |
| 244 | texture: Some(offscreen.texture), | 254 | texture: Some(offscreen.texture), |
| 245 | }) | 255 | }) |
| 246 | } | 256 | } |
| 247 | Backend::Gl { .. } => Ok(Offscreen { | 257 | Backend::Native { .. } => Ok(Offscreen { |
| 248 | target: GlTarget::new(self.size)?.into(), | 258 | target: renderer.target(self.size)?, |
| 249 | texture: None, | 259 | texture: None, |
| 250 | }), | 260 | }), |
| 251 | } | 261 | } |
| ... | @@ -261,7 +271,7 @@ impl Surface { | ... | @@ -261,7 +271,7 @@ impl Surface { |
| 261 | (Backend::Wgpu(surface), target, Some(texture)) => { | 271 | (Backend::Wgpu(surface), target, Some(texture)) => { |
| 262 | surface.read(renderer, webgpu::Offscreen { target, texture }) | 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 | _ => unreachable!("Snapshots come from the surface's own backend"), | 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,7 +39,7 @@ fn describe( |
| 39 | quiet: bool, | 39 | quiet: bool, |
| 40 | ) -> (&'static str, &'static [&'static str]) { | 40 | ) -> (&'static str, &'static [&'static str]) { |
| 41 | if offline { | 41 | if offline { |
| 42 | return ("Working offline", art::SYNC_OFFLINE); | 42 | return ("Offline", art::SYNC_OFFLINE); |
| 43 | } | 43 | } |
| 44 | match sync.state() { | 44 | match sync.state() { |
| 45 | SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), | 45 | SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), |
| ... | @@ -78,14 +78,6 @@ fn brief(sync: &SyncStatus, offline: bool) -> String { | ... | @@ -78,14 +78,6 @@ fn brief(sync: &SyncStatus, offline: bool) -> String { |
| 78 | /// out the recycle bin, unless something is wrong there. | 78 | /// out the recycle bin, unless something is wrong there. |
| 79 | fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { | 79 | fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { |
| 80 | let open = &session.tabs[session.tab].path; | 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 | let mut sections = library | 81 | let mut sections = library |
| 90 | .background | 82 | .background |
| 91 | .as_ref() | 83 | .as_ref() |
| ... | @@ -120,13 +112,21 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { | ... | @@ -120,13 +112,21 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { |
| 120 | .map(|(_, sync)| sync.synced) | 112 | .map(|(_, sync)| sync.synced) |
| 121 | .collect::<Option<Vec<_>>>() | 113 | .collect::<Option<Vec<_>>>() |
| 122 | .and_then(|times| times.into_iter().min()), | 114 | .and_then(|times| times.into_iter().min()), |
| 123 | error: worst | 115 | error: worst.and_then(|sync| copy(sync).error), |
| 124 | .and_then(|sync| sync.error.as_ref()) | ||
| 125 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | ||
| 126 | queued: sections.iter().map(|(_, sync)| sync.queued).sum(), | 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 | fn changes(count: u64) -> String { | 130 | fn changes(count: u64) -> String { |
| 131 | match count { | 131 | match count { |
| 132 | 1 => "1 change".to_owned(), | 132 | 1 => "1 change".to_owned(), |
| ... | @@ -175,8 +175,8 @@ const TITLE: &str = "Notebook Sync Status"; | ... | @@ -175,8 +175,8 @@ const TITLE: &str = "Notebook Sync Status"; |
| 175 | fn update_note(update: &update::Status) -> Option<String> { | 175 | fn update_note(update: &update::Status) -> Option<String> { |
| 176 | match update { | 176 | match update { |
| 177 | update::Status::Downloading(version) => Some(format!("Downloading {version}…")), | 177 | update::Status::Downloading(version) => Some(format!("Downloading {version}…")), |
| 178 | update::Status::Ready(version, _) => Some(format!("{version} is ready")), | 178 | update::Status::Ready(version, ..) => Some(format!("{version} is ready")), |
| 179 | update::Status::Available(version) => Some(format!("{version} is available")), | 179 | update::Status::Available(version, ..) => Some(format!("{version} is available")), |
| 180 | _ => None, | 180 | _ => None, |
| 181 | } | 181 | } |
| 182 | } | 182 | } |
| ... | @@ -209,7 +209,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t | ... | @@ -209,7 +209,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t |
| 209 | ); | 209 | ); |
| 210 | let waiting = matches!( | 210 | let waiting = matches!( |
| 211 | update, | 211 | update, |
| 212 | update::Status::Ready(..) | update::Status::Available(_) | 212 | update::Status::Ready(..) | update::Status::Available(..) |
| 213 | ); | 213 | ); |
| 214 | if waiting { | 214 | if waiting { |
| 215 | ui.leaf( | 215 | ui.leaf( |
| ... | @@ -259,6 +259,8 @@ struct Facts<'a> { | ... | @@ -259,6 +259,8 @@ struct Facts<'a> { |
| 259 | conflicts: Vec<(ExGuid, &'a str)>, | 259 | conflicts: Vec<(ExGuid, &'a str)>, |
| 260 | offline: bool, | 260 | offline: bool, |
| 261 | update: &'a update::Status, | 261 | update: &'a update::Status, |
| 262 | /// Whether the update's changes are listed. | ||
| 263 | changes_listed: bool, | ||
| 262 | /// Whether the open section's file can be shown in the file manager. | 264 | /// Whether the open section's file can be shown in the file manager. |
| 263 | local: bool, | 265 | local: bool, |
| 264 | } | 266 | } |
| ... | @@ -273,6 +275,126 @@ struct Picked { | ... | @@ -273,6 +275,126 @@ struct Picked { |
| 273 | conflict: Option<ExGuid>, | 275 | conflict: Option<ExGuid>, |
| 274 | build_folder: bool, | 276 | build_folder: bool, |
| 275 | restart: bool, | 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 | /// Lays out the open popup's contents. | 400 | /// Lays out the open popup's contents. |
| ... | @@ -320,10 +442,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -320,10 +442,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 320 | let waiting = changes(sync.queued); | 442 | let waiting = changes(sync.queued); |
| 321 | let conflict; | 443 | let conflict; |
| 322 | let advice = if facts.offline { | 444 | let advice = if facts.offline { |
| 323 | Some(match sync.queued { | 445 | None |
| 324 | 0 => "Turn off Work offline to sync.".to_owned(), | ||
| 325 | _ => format!("{waiting} will sync when you go back online."), | ||
| 326 | }) | ||
| 327 | } else { | 446 | } else { |
| 328 | match state { | 447 | match state { |
| 329 | SyncState::NotConnected => Some(match sync.queued { | 448 | SyncState::NotConnected => Some(match sync.queued { |
| ... | @@ -354,7 +473,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -354,7 +473,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 354 | icon = art::SYNC_WARNING; | 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 | ui.open( | 479 | ui.open( |
| 359 | "status", | 480 | "status", |
| 360 | Spec { | 481 | Spec { |
| ... | @@ -367,10 +488,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -367,10 +488,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 367 | if let Some(node) = ui.access(ui.current()) { | 488 | if let Some(node) = ui.access(ui.current()) { |
| 368 | node.set_live(accesskit::Live::Polite); | 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 | ui.leaf( | 500 | ui.leaf( |
| 371 | "headline", | 501 | "headline", |
| 372 | Spec { | 502 | Spec { |
| 373 | size: [fill(), px(theme.font_size * 2.0)], | 503 | size: [fill(), px(height)], |
| 374 | text: Some(headline), | 504 | text: Some(headline), |
| 375 | icon: Some(icon), | 505 | icon: Some(icon), |
| 376 | font_size: Some(theme.font_size * 1.2), | 506 | font_size: Some(theme.font_size * 1.2), |
| ... | @@ -379,12 +509,118 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -379,12 +509,118 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 379 | ..Spec::default() | 509 | ..Spec::default() |
| 380 | }, | 510 | }, |
| 381 | ); | 511 | ); |
| 382 | if showing { | 512 | let switch = ui.open( |
| 383 | let fraction = match total { | 513 | "offline", |
| 384 | 0 => 1.0, | 514 | Spec { |
| 385 | _ if state == SyncState::UpToDate => 1.0, | 515 | flags: Flags::CLICKABLE, |
| 386 | _ => done as f32 / total as f32, | 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 | let fraction = ui.animate(bar.child("fraction"), fraction); | 624 | let fraction = ui.animate(bar.child("fraction"), fraction); |
| 389 | let running = ui.lasted(bar.child("running"), true).as_secs_f32(); | 625 | let running = ui.lasted(bar.child("running"), true).as_secs_f32(); |
| 390 | ui.open_as( | 626 | ui.open_as( |
| ... | @@ -419,48 +655,18 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -419,48 +655,18 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 419 | node.set_numeric_value(f64::from((fraction * 100.0).round())); | 655 | node.set_numeric_value(f64::from((fraction * 100.0).round())); |
| 420 | } | 656 | } |
| 421 | ui.close(); | 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 | } else { | 658 | } else { |
| 437 | if let Some(advice) = &advice { | 659 | ui.leaf( |
| 438 | text(ui, "advice", advice, theme.text); | 660 | "rule", |
| 439 | } else if !facts.conflicts.is_empty() && headline != "Syncing…" { | 661 | Spec { |
| 440 | text( | 662 | size: [fill(), px(6.0)], |
| 441 | ui, | 663 | inset: [0.0, 2.5, 0.0, 2.5], |
| 442 | "advice", | 664 | fill: Some(theme.chip), |
| 443 | "Both versions are kept. Open the page to compare them.", | 665 | ..Spec::default() |
| 444 | theme.text, | 666 | }, |
| 445 | ); | 667 | ); |
| 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 | } | ||
| 460 | } | 668 | } |
| 461 | ui.close(); | 669 | ui.close(); |
| 462 | |||
| 463 | rule(ui, "identity-rule"); | ||
| 464 | // Which notebook, and where it lives; the full location in the tooltip. | 670 | // Which notebook, and where it lives; the full location in the tooltip. |
| 465 | ui.open( | 671 | ui.open( |
| 466 | "identity", | 672 | "identity", |
| ... | @@ -528,9 +734,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -528,9 +734,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 528 | ); | 734 | ); |
| 529 | // Where the whole notebook can't be reached, the headline says so for each section | 735 | // Where the whole notebook can't be reached, the headline says so for each section |
| 530 | // without changes waiting. | 736 | // without changes waiting. |
| 531 | let unreached = |sync: &SyncStatus| { | 737 | let unreached = |sync: &SyncStatus| sync.state() == SyncState::NotConnected && sync.queued == 0; |
| 532 | !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0 | ||
| 533 | }; | ||
| 534 | let behind: Vec<_> = facts | 738 | let behind: Vec<_> = facts |
| 535 | .sections | 739 | .sections |
| 536 | .iter() | 740 | .iter() |
| ... | @@ -650,17 +854,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -650,17 +854,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 650 | ui.open( | 854 | ui.open( |
| 651 | "update", | 855 | "update", |
| 652 | Spec { | 856 | Spec { |
| 857 | axis: Axis::Y, | ||
| 653 | size: [fill(), children()], | 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 | gap: 8.0, | 875 | gap: 8.0, |
| 655 | ..Spec::default() | 876 | ..Spec::default() |
| 656 | }, | 877 | }, |
| 657 | ); | 878 | ); |
| 658 | ui.leaf( | 879 | ui.leaf( |
| 659 | "note", | 880 | "space", |
| 660 | Spec { | 881 | Spec { |
| 661 | size: [fill(), px(theme.font_size * 2.0)], | 882 | size: [fill(), px(1.0)], |
| 662 | text: Some(&format!("Snowbound {note}")), | ||
| 663 | overflow: Overflow::Ellipsis, | ||
| 664 | ..Spec::default() | 883 | ..Spec::default() |
| 665 | }, | 884 | }, |
| 666 | ); | 885 | ); |
| ... | @@ -669,6 +888,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -669,6 +888,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 669 | picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; | 888 | picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; |
| 670 | } | 889 | } |
| 671 | ui.close(); | 890 | ui.close(); |
| 891 | ui.close(); | ||
| 672 | } | 892 | } |
| 673 | 893 | ||
| 674 | ui.open( | 894 | ui.open( |
| ... | @@ -680,7 +900,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -680,7 +900,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 680 | ..Spec::default() | 900 | ..Spec::default() |
| 681 | }, | 901 | }, |
| 682 | ); | 902 | ); |
| 683 | picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked; | ||
| 684 | ui.leaf( | 903 | ui.leaf( |
| 685 | "space", | 904 | "space", |
| 686 | Spec { | 905 | Spec { |
| ... | @@ -688,30 +907,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { | ... | @@ -688,30 +907,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 688 | ..Spec::default() | 907 | ..Spec::default() |
| 689 | }, | 908 | }, |
| 690 | ); | 909 | ); |
| 691 | picked.show_file = match facts.local { | 910 | // A button that can't act now, dimmed. |
| 692 | true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, | 911 | let button = |ui: &mut Ui, part: &str, label: &str, enabled: bool| { |
| 693 | false => { | 912 | if enabled { |
| 694 | ui.leaf( | 913 | return ui::button(ui, part, label).clicked; |
| 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 | ||
| 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 | ui.close(); | 936 | ui.close(); |
| 716 | picked | 937 | picked |
| 717 | } | 938 | } |
| ... | @@ -817,6 +1038,7 @@ impl State { | ... | @@ -817,6 +1038,7 @@ impl State { |
| 817 | .collect(), | 1038 | .collect(), |
| 818 | offline, | 1039 | offline, |
| 819 | update: &update, | 1040 | update: &update, |
| 1041 | changes_listed: self.updates.changes_listed, | ||
| 820 | local: file.is_some(), | 1042 | local: file.is_some(), |
| 821 | }; | 1043 | }; |
| 822 | open(ui, anchor); | 1044 | open(ui, anchor); |
| ... | @@ -852,13 +1074,14 @@ impl State { | ... | @@ -852,13 +1074,14 @@ impl State { |
| 852 | } | 1074 | } |
| 853 | match update { | 1075 | match update { |
| 854 | update::Status::Downloading(version) | 1076 | update::Status::Downloading(version) |
| 855 | | update::Status::Ready(version, _) | 1077 | | update::Status::Ready(version, ..) |
| 856 | | update::Status::Available(version) | 1078 | | update::Status::Available(version, ..) |
| 857 | if picked.build_folder => | 1079 | if picked.build_folder => |
| 858 | { | 1080 | { |
| 859 | update::show_build(&version) | 1081 | update::show_build(&version) |
| 860 | } | 1082 | } |
| 861 | _ if picked.restart => self.restart_to_update(), | 1083 | _ if picked.restart => self.restart_to_update(), |
| 1084 | _ if picked.list_changes => self.updates.changes_listed ^= true, | ||
| 862 | _ => {} | 1085 | _ => {} |
| 863 | } | 1086 | } |
| 864 | Ok(()) | 1087 | Ok(()) |
| ... | @@ -915,6 +1138,7 @@ mod tests { | ... | @@ -915,6 +1138,7 @@ mod tests { |
| 915 | conflicts: Vec::new(), | 1138 | conflicts: Vec::new(), |
| 916 | offline: false, | 1139 | offline: false, |
| 917 | update: &IDLE, | 1140 | update: &IDLE, |
| 1141 | changes_listed: false, | ||
| 918 | local: true, | 1142 | local: true, |
| 919 | } | 1143 | } |
| 920 | } | 1144 | } |
| ... | @@ -1110,16 +1334,11 @@ mod tests { | ... | @@ -1110,16 +1334,11 @@ mod tests { |
| 1110 | assert!(!bar(&ui)); | 1334 | assert!(!bar(&ui)); |
| 1111 | } | 1335 | } |
| 1112 | 1336 | ||
| 1113 | #[cfg(all(feature = "wgpu", not(windows)))] | 1337 | type Made = fn() -> Facts<'static>; |
| 1114 | #[test] | 1338 | |
| 1115 | fn popup_renders_each_state() { | 1339 | /// Each state the popup shows, by name. |
| 1116 | let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); | 1340 | fn states() -> Vec<(&'static str, Made)> { |
| 1117 | let mut gpu = output.as_ref().map(|output| { | 1341 | vec![ |
| 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![ | ||
| 1123 | ("up-to-date", || facts(sections(|_| status(true, 0, None)))), | 1342 | ("up-to-date", || facts(sections(|_| status(true, 0, None)))), |
| 1124 | ("syncing", || { | 1343 | ("syncing", || { |
| 1125 | facts(sections(|index| match index { | 1344 | facts(sections(|index| match index { |
| ... | @@ -1178,7 +1397,71 @@ mod tests { | ... | @@ -1178,7 +1397,71 @@ mod tests { |
| 1178 | place: vec!["iCloud Drive".into(), "Notes".into()], | 1397 | place: vec!["iCloud Drive".into(), "Notes".into()], |
| 1179 | ..facts(sections(|_| status(true, 0, None))) | 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 | for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { | 1465 | for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { |
| 1183 | for (name, facts) in &states { | 1466 | for (name, facts) in &states { |
| 1184 | let mut ui = ui(appearance); | 1467 | let mut ui = ui(appearance); |
| ... | @@ -1226,6 +1509,32 @@ mod tests { | ... | @@ -1226,6 +1509,32 @@ mod tests { |
| 1226 | shot += 1; | 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,26 +345,6 @@ fn picker( |
| 345 | Anchor::Below(ui.rect(button).unwrap_or_default()) | 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 | /// The frame of a dialog titled `title`, centred in the window. | 348 | /// The frame of a dialog titled `title`, centred in the window. |
| 369 | fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { | 349 | fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { |
| 370 | let theme = ui.theme.clone(); | 350 | let theme = ui.theme.clone(); |
| ... | @@ -545,7 +525,7 @@ impl State { | ... | @@ -545,7 +525,7 @@ impl State { |
| 545 | .flatten() | 525 | .flatten() |
| 546 | }); | 526 | }); |
| 547 | ui.close(); | 527 | ui.close(); |
| 548 | buttons(ui); | 528 | crate::buttons(ui); |
| 549 | let ok = button(ui, "ok", "OK", list.tags != self.tags); | 529 | let ok = button(ui, "ok", "OK", list.tags != self.tags); |
| 550 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 530 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 551 | ui.close(); | 531 | ui.close(); |
| ... | @@ -880,7 +860,7 @@ fn tag_editor( | ... | @@ -880,7 +860,7 @@ fn tag_editor( |
| 880 | ..Spec::default() | 860 | ..Spec::default() |
| 881 | }, | 861 | }, |
| 882 | ); | 862 | ); |
| 883 | buttons(ui); | 863 | crate::buttons(ui); |
| 884 | let named = !tag.label.trim().is_empty(); | 864 | let named = !tag.label.trim().is_empty(); |
| 885 | let ok = button(ui, "ok", "OK", named) || entered && named; | 865 | let ok = button(ui, "ok", "OK", named) || entered && named; |
| 886 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 866 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
crates/snowbound/src/themes.rs+1-16| ... | @@ -156,22 +156,7 @@ impl State { | ... | @@ -156,22 +156,7 @@ impl State { |
| 156 | return Ok(()); | 156 | return Ok(()); |
| 157 | } | 157 | } |
| 158 | self.persist()?; | 158 | self.persist()?; |
| 159 | let Some(session) = &self.session else { | 159 | self.edit_page(ops) |
| 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() | ||
| 175 | } | 160 | } |
| 176 | 161 | ||
| 177 | /// Opens the Themes dialog choosing a theme for `scope` of the open page. | 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,6 +3,7 @@ |
| 3 | //! window keeps what was done to pages and sections between those edits, so Undo takes back | 3 | //! window keeps what was done to pages and sections between those edits, so Undo takes back |
| 4 | //! whichever came last where the user is. Every step taken back is an edit of its own. | 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 | use crate::{Command, Library, Loaded, State, UserEvent}; | 7 | use crate::{Command, Library, Loaded, State, UserEvent}; |
| 7 | use canvas::editor::CanvasEditor; | 8 | use canvas::editor::CanvasEditor; |
| 8 | use notebook::{Replica, discover::Folder}; | 9 | use notebook::{Replica, discover::Folder}; |
| ... | @@ -287,7 +288,7 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { | ... | @@ -287,7 +288,7 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { |
| 287 | } | 288 | } |
| 288 | 289 | ||
| 289 | /// Whether `page` holds nothing but its title, untitled. | 290 | /// Whether `page` holds nothing but its title, untitled. |
| 290 | fn blank(page: &Page) -> bool { | 291 | pub(crate) fn blank(page: &Page) -> bool { |
| 291 | page.title.trim().is_empty() | 292 | page.title.trim().is_empty() |
| 292 | && page | 293 | && page |
| 293 | .objects | 294 | .objects |
| ... | @@ -353,6 +354,46 @@ impl Site { | ... | @@ -353,6 +354,46 @@ impl Site { |
| 353 | Ok(()) | 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 | /// The catalog path of the section `identity` names. | 397 | /// The catalog path of the section `identity` names. |
| 357 | fn section_path(&self, identity: [u8; 16]) -> Option<String> { | 398 | fn section_path(&self, identity: [u8; 16]) -> Option<String> { |
| 358 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) | 399 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) |
| ... | @@ -397,14 +438,7 @@ impl Site { | ... | @@ -397,14 +438,7 @@ impl Site { |
| 397 | }) | 438 | }) |
| 398 | .collect(); | 439 | .collect(); |
| 399 | let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; | 440 | let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; |
| 400 | let (show, added) = match neighbor(&listed, &pages, show) { | 441 | let (show, added) = self.after(&listed, &pages, show, &mut ops); |
| 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 | }; | ||
| 408 | if !created && binned { | 442 | if !created && binned { |
| 409 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); | 443 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); |
| 410 | self.library.reopen()?.recycle_pages(&pages, &self.author)?; | 444 | self.library.reopen()?.recycle_pages(&pages, &self.author)?; |
| ... | @@ -452,15 +486,7 @@ impl Site { | ... | @@ -452,15 +486,7 @@ impl Site { |
| 452 | )?]))); | 486 | )?]))); |
| 453 | spaces.push(space); | 487 | spaces.push(space); |
| 454 | } | 488 | } |
| 455 | let gone: Vec<ExGuid> = added | 489 | let gone = self.take_added(added, &listed, &mut ops); |
| 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 | } | ||
| 464 | self.apply(ops)?; | 490 | self.apply(ops)?; |
| 465 | if !created && binned { | 491 | if !created && binned { |
| 466 | let identities: Vec<[u8; 16]> = | 492 | let identities: Vec<[u8; 16]> = |
| ... | @@ -531,14 +557,7 @@ impl Site { | ... | @@ -531,14 +557,7 @@ impl Site { |
| 531 | let before = listed[from + 1..].first().map(|(space, ..)| *space); | 557 | let before = listed[from + 1..].first().map(|(space, ..)| *space); |
| 532 | let level = listed[from].2; | 558 | let level = listed[from].2; |
| 533 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; | 559 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; |
| 534 | let (show, added) = match neighbor(&listed, &[page], None) { | 560 | let (show, added) = self.after(&listed, &[page], None, &mut ops); |
| 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 | }; | ||
| 542 | let section = self.library.open(&path, || {})?; | 561 | let section = self.library.open(&path, || {})?; |
| 543 | section.replica().apply( | 562 | section.replica().apply( |
| 544 | &self.author, | 563 | &self.author, |
| ... | @@ -590,15 +609,7 @@ impl Site { | ... | @@ -590,15 +609,7 @@ impl Site { |
| 590 | level, | 609 | level, |
| 591 | )?])), | 610 | )?])), |
| 592 | ]; | 611 | ]; |
| 593 | let gone: Vec<ExGuid> = added | 612 | let gone = self.take_added(added, &listed, &mut ops); |
| 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 | } | ||
| 602 | self.apply(ops)?; | 613 | self.apply(ops)?; |
| 603 | section.replica().apply( | 614 | section.replica().apply( |
| 604 | &self.author, | 615 | &self.author, |
| ... | @@ -641,21 +652,10 @@ fn folder_id(folder: &Folder) -> Option<[u8; 16]> { | ... | @@ -641,21 +652,10 @@ fn folder_id(folder: &Folder) -> Option<[u8; 16]> { |
| 641 | (!folder.path.is_empty()).then_some(toc.file_id) | 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 | /// The folder `identity` names, the notebook's own without one. | 655 | /// The folder `identity` names, the notebook's own without one. |
| 656 | fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { | 656 | fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { |
| 657 | match identity { | 657 | match identity { |
| 658 | Some(_) => folders(catalog) | 658 | Some(_) => folders(catalog, |_| true) |
| 659 | .into_iter() | 659 | .into_iter() |
| 660 | .find(|folder| folder_id(folder) == identity), | 660 | .find(|folder| folder_id(folder) == identity), |
| 661 | None => Some(catalog), | 661 | None => Some(catalog), |
| ... | @@ -664,7 +664,7 @@ fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { | ... | @@ -664,7 +664,7 @@ fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { |
| 664 | 664 | ||
| 665 | /// The section or group `identity` names: its folder and catalog path. | 665 | /// The section or group `identity` names: its folder and catalog path. |
| 666 | fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { | 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 | let (_, path) = entries(folder) | 668 | let (_, path) = entries(folder) |
| 669 | .into_iter() | 669 | .into_iter() |
| 670 | .find(|(id, _)| *id == identity)?; | 670 | .find(|(id, _)| *id == identity)?; |
| ... | @@ -674,18 +674,12 @@ fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { | ... | @@ -674,18 +674,12 @@ fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { |
| 674 | 674 | ||
| 675 | /// The identity of the section or group at catalog `path`. | 675 | /// The identity of the section or group at catalog `path`. |
| 676 | fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> { | 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 | let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; | 678 | let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; |
| 679 | Some(id) | 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 | /// The action taking back `change` to `library`'s sections and groups, which is about to be | 683 | /// The action taking back `change` to `library`'s sections and groups, which is about to be |
| 690 | /// made; none for changes Undo does not take back, as OneNote 2010 takes back none. | 684 | /// made; none for changes Undo does not take back, as OneNote 2010 takes back none. |
| 691 | pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option<Action> { | 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,7 +689,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O |
| 695 | Structure::Rename { path, name } => Change::Rename { | 689 | Structure::Rename { path, name } => Change::Rename { |
| 696 | entry: identity_at(catalog, path)?, | 690 | entry: identity_at(catalog, path)?, |
| 697 | from: name.clone(), | 691 | from: name.clone(), |
| 698 | to: name_of(path).to_owned(), | 692 | to: entry_name(path).to_owned(), |
| 699 | }, | 693 | }, |
| 700 | Structure::Move { path, .. } => { | 694 | Structure::Move { path, .. } => { |
| 701 | let entry = identity_at(catalog, path)?; | 695 | let entry = identity_at(catalog, path)?; |
| ... | @@ -707,7 +701,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O | ... | @@ -707,7 +701,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O |
| 707 | } | 701 | } |
| 708 | } | 702 | } |
| 709 | Structure::Reorder { folder, .. } => { | 703 | Structure::Reorder { folder, .. } => { |
| 710 | let folder = folders(catalog) | 704 | let folder = folders(catalog, |_| true) |
| 711 | .into_iter() | 705 | .into_iter() |
| 712 | .find(|listed| listed.path == *folder)?; | 706 | .find(|listed| listed.path == *folder)?; |
| 713 | Change::Place { | 707 | Change::Place { |
| ... | @@ -733,7 +727,7 @@ fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::S | ... | @@ -733,7 +727,7 @@ fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::S |
| 733 | let (structure, undo) = match change { | 727 | let (structure, undo) = match change { |
| 734 | Change::Rename { entry, from, to } => { | 728 | Change::Rename { entry, from, to } => { |
| 735 | let (_, path) = entry_of(catalog, *entry)?; | 729 | let (_, path) = entry_of(catalog, *entry)?; |
| 736 | if name_of(&path) != from { | 730 | if entry_name(&path) != from { |
| 737 | return None; | 731 | return None; |
| 738 | } | 732 | } |
| 739 | ( | 733 | ( |
| ... | @@ -1450,7 +1444,7 @@ mod tests { | ... | @@ -1450,7 +1444,7 @@ mod tests { |
| 1450 | } | 1444 | } |
| 1451 | notebook.create_group("", "Group").unwrap(); | 1445 | notebook.create_group("", "Group").unwrap(); |
| 1452 | let order = |notebook: &Notebook, folder: &str| -> Vec<String> { | 1446 | let order = |notebook: &Notebook, folder: &str| -> Vec<String> { |
| 1453 | let folder = folders(notebook.catalog()) | 1447 | let folder = folders(notebook.catalog(), |_| true) |
| 1454 | .into_iter() | 1448 | .into_iter() |
| 1455 | .find(|listed| listed.path == folder) | 1449 | .find(|listed| listed.path == folder) |
| 1456 | .unwrap(); | 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,7 +73,7 @@ pub struct Version { |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | impl Version { | 75 | impl Version { |
| 76 | fn parse(name: &str) -> Option<Self> { | 76 | pub(crate) fn parse(name: &str) -> Option<Self> { |
| 77 | let (date, revision) = name.split_once("-r")?; | 77 | let (date, revision) = name.split_once("-r")?; |
| 78 | let digits = |range: std::ops::Range<usize>| { | 78 | let digits = |range: std::ops::Range<usize>| { |
| 79 | date.get(range) | 79 | date.get(range) |
| ... | @@ -120,6 +120,64 @@ pub fn describe_running() -> String { | ... | @@ -120,6 +120,64 @@ pub fn describe_running() -> String { |
| 120 | struct Build { | 120 | struct Build { |
| 121 | version: String, | 121 | version: String, |
| 122 | archives: HashMap<String, Archive>, | 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 | #[derive(Clone, Debug, Deserialize)] | 183 | #[derive(Clone, Debug, Deserialize)] |
| ... | @@ -169,25 +227,35 @@ fn newer( | ... | @@ -169,25 +227,35 @@ fn newer( |
| 169 | } | 227 | } |
| 170 | 228 | ||
| 171 | /// `platform`'s archive in the build `build.json` describes, once its signature holds and | 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 | fn archive( | 231 | fn archive( |
| 174 | key: &[u8], | 232 | key: &[u8], |
| 175 | build: &[u8], | 233 | build: &[u8], |
| 176 | signature: &[u8], | 234 | signature: &[u8], |
| 177 | version: &Version, | 235 | version: &Version, |
| 178 | platform: &str, | 236 | platform: &str, |
| 179 | ) -> Result<Archive, String> { | 237 | running: Option<&Version>, |
| 238 | ) -> Result<(Archive, Vec<Change>), String> { | ||
| 180 | verify(key, build, &String::from_utf8_lossy(signature))?; | 239 | verify(key, build, &String::from_utf8_lossy(signature))?; |
| 181 | let build: Build = | 240 | let mut build: Build = |
| 182 | serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; | 241 | serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; |
| 183 | if Version::parse(&build.version).as_ref() != Some(version) { | 242 | if Version::parse(&build.version).as_ref() != Some(version) { |
| 184 | return Err(format!("build.json describes {}", build.version)); | 243 | return Err(format!("build.json describes {}", build.version)); |
| 185 | } | 244 | } |
| 186 | build | 245 | let archive = build |
| 187 | .archives | 246 | .archives |
| 188 | .get(platform) | 247 | .remove(platform) |
| 189 | .cloned() | 248 | .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?; |
| 190 | .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 | fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { | 261 | fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { |
| ... | @@ -302,10 +370,11 @@ pub enum Status { | ... | @@ -302,10 +370,11 @@ pub enum Status { |
| 302 | Idle, | 370 | Idle, |
| 303 | UpToDate, | 371 | UpToDate, |
| 304 | Downloading(Version), | 372 | Downloading(Version), |
| 305 | /// Verified and unpacked beside the install, waiting for Restart to Update. | 373 | /// Verified and unpacked beside the install, waiting for Restart to Update, with what it |
| 306 | Ready(Version, PathBuf), | 374 | /// changes. |
| 375 | Ready(Version, PathBuf, Vec<Change>), | ||
| 307 | /// Newer than this build, which can't install it itself: its folder has the download. | 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 | Failed(&'static str), | 378 | Failed(&'static str), |
| 310 | } | 379 | } |
| 311 | 380 | ||
| ... | @@ -326,6 +395,7 @@ fn check( | ... | @@ -326,6 +395,7 @@ fn check( |
| 326 | progress: &dyn Fn(Status), | 395 | progress: &dyn Fn(Status), |
| 327 | ) -> Status { | 396 | ) -> Status { |
| 328 | let platform = platform(); | 397 | let platform = platform(); |
| 398 | let since = running; | ||
| 329 | // Under Rosetta the native build of the same version is still the update. | 399 | // Under Rosetta the native build of the same version is still the update. |
| 330 | let running = running.filter(|_| !translated()); | 400 | let running = running.filter(|_| !translated()); |
| 331 | let fetch = |path: &str, limit| { | 401 | let fetch = |path: &str, limit| { |
| ... | @@ -346,15 +416,16 @@ fn check( | ... | @@ -346,15 +416,16 @@ fn check( |
| 346 | }; | 416 | }; |
| 347 | let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; | 417 | let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; |
| 348 | let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; | 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 | let Some(folder) = install.and_then(staging) else { | 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 | let item = staged(&folder); | 424 | let item = staged(&folder); |
| 354 | if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name()) | 425 | if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name()) |
| 355 | && item.exists() | 426 | && item.exists() |
| 356 | { | 427 | { |
| 357 | return Ok(Status::Ready(version, item)); | 428 | return Ok(Status::Ready(version, item, changes)); |
| 358 | } | 429 | } |
| 359 | progress(Status::Downloading(version.clone())); | 430 | progress(Status::Downloading(version.clone())); |
| 360 | let bytes = fetch( | 431 | let bytes = fetch( |
| ... | @@ -363,10 +434,10 @@ fn check( | ... | @@ -363,10 +434,10 @@ fn check( |
| 363 | )?; | 434 | )?; |
| 364 | check_archive(key, &archive, &bytes).map_err(unverified)?; | 435 | check_archive(key, &archive, &bytes).map_err(unverified)?; |
| 365 | Ok(match stage(&bytes, &folder, &version) { | 436 | Ok(match stage(&bytes, &folder, &version) { |
| 366 | Ok(item) => Status::Ready(version, item), | 437 | Ok(item) => Status::Ready(version, item, changes), |
| 367 | Err(error) => { | 438 | Err(error) => { |
| 368 | eprintln!("Cannot stage the update in {}: {error}", folder.display()); | 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,6 +481,8 @@ pub struct Updates { |
| 410 | thread: std::thread::Thread, | 481 | thread: std::thread::Thread, |
| 411 | /// Restart to Update was chosen: swap the staged build in once the app quits. | 482 | /// Restart to Update was chosen: swap the staged build in once the app quits. |
| 412 | restart: bool, | 483 | restart: bool, |
| 484 | /// The sync popup lists what the update changes. | ||
| 485 | pub(crate) changes_listed: bool, | ||
| 413 | } | 486 | } |
| 414 | 487 | ||
| 415 | impl Updates { | 488 | impl Updates { |
| ... | @@ -474,6 +547,7 @@ impl Updates { | ... | @@ -474,6 +547,7 @@ impl Updates { |
| 474 | shared, | 547 | shared, |
| 475 | thread, | 548 | thread, |
| 476 | restart: false, | 549 | restart: false, |
| 550 | changes_listed: false, | ||
| 477 | } | 551 | } |
| 478 | } | 552 | } |
| 479 | 553 | ||
| ... | @@ -499,7 +573,7 @@ impl Updates { | ... | @@ -499,7 +573,7 @@ impl Updates { |
| 499 | /// The staged build to swap in, once Restart to Update has quit the app. | 573 | /// The staged build to swap in, once Restart to Update has quit the app. |
| 500 | pub fn restarting(&self) -> Option<PathBuf> { | 574 | pub fn restarting(&self) -> Option<PathBuf> { |
| 501 | match self.status() { | 575 | match self.status() { |
| 502 | Status::Ready(_, staged) if self.restart => Some(staged), | 576 | Status::Ready(_, staged, _) if self.restart => Some(staged), |
| 503 | _ => None, | 577 | _ => None, |
| 504 | } | 578 | } |
| 505 | } | 579 | } |
| ... | @@ -518,20 +592,26 @@ impl State { | ... | @@ -518,20 +592,26 @@ impl State { |
| 518 | return; | 592 | return; |
| 519 | }; | 593 | }; |
| 520 | match status { | 594 | match status { |
| 521 | Status::Ready(version, _) => { | 595 | Status::Ready(version, _, changes) => { |
| 522 | if platform::confirm( | 596 | if platform::confirm( |
| 523 | "Update ready", | 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 | "Later", | 602 | "Later", |
| 526 | "Restart to Update", | 603 | "Restart to Update", |
| 527 | ) { | 604 | ) { |
| 528 | self.restart_to_update(); | 605 | self.restart_to_update(); |
| 529 | } | 606 | } |
| 530 | } | 607 | } |
| 531 | Status::Available(version) => { | 608 | Status::Available(version, changes) => { |
| 532 | if platform::confirm( | 609 | if platform::confirm( |
| 533 | "Update available", | 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 | "Later", | 615 | "Later", |
| 536 | "Open Build Folder", | 616 | "Open Build Folder", |
| 537 | ) { | 617 | ) { |
| ... | @@ -548,6 +628,28 @@ impl State { | ... | @@ -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 | /// Opens the folder a build is published in. | 653 | /// Opens the folder a build is published in. |
| 552 | pub fn show_build(version: &Version) { | 654 | pub fn show_build(version: &Version) { |
| 553 | platform::reveal(format!("{BASE}{}", version.folder())); | 655 | platform::reveal(format!("{BASE}{}", version.folder())); |
| ... | @@ -696,6 +798,18 @@ mod tests { | ... | @@ -696,6 +798,18 @@ mod tests { |
| 696 | assert!(newer(b"<html>", "macos-aarch64", None).is_err()); | 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 | /// A build.json for `archive`'s bytes under `platform`, with its signature. | 813 | /// A build.json for `archive`'s bytes under `platform`, with its signature. |
| 700 | fn publish( | 814 | fn publish( |
| 701 | pair: &Ed25519KeyPair, | 815 | pair: &Ed25519KeyPair, |
| ... | @@ -708,6 +822,7 @@ mod tests { | ... | @@ -708,6 +822,7 @@ mod tests { |
| 708 | let build = serde_json::to_vec_pretty(&serde_json::json!({ | 822 | let build = serde_json::to_vec_pretty(&serde_json::json!({ |
| 709 | "version": name, | 823 | "version": name, |
| 710 | "commit": "0123456789abcdef", | 824 | "commit": "0123456789abcdef", |
| 825 | "changes": changes(), | ||
| 711 | "archives": {platform: { | 826 | "archives": {platform: { |
| 712 | "file": file, | 827 | "file": file, |
| 713 | "size": bytes.len(), | 828 | "size": bytes.len(), |
| ... | @@ -728,35 +843,37 @@ mod tests { | ... | @@ -728,35 +843,37 @@ mod tests { |
| 728 | let bytes = b"an archive".to_vec(); | 843 | let bytes = b"an archive".to_vec(); |
| 729 | let (build, signature) = | 844 | let (build, signature) = |
| 730 | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); | 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 | check_archive(key, &found, &bytes).unwrap(); | 847 | check_archive(key, &found, &bytes).unwrap(); |
| 733 | 848 | ||
| 734 | let mut tampered = build.clone(); | 849 | let mut tampered = build.clone(); |
| 735 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); | 850 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); |
| 736 | tampered[at] = b'b'; | 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 | assert!( | 853 | assert!( |
| 739 | archive( | 854 | archive( |
| 740 | generate().public_key().as_ref(), | 855 | generate().public_key().as_ref(), |
| 741 | &build, | 856 | &build, |
| 742 | &signature, | 857 | &signature, |
| 743 | &tenth, | 858 | &tenth, |
| 744 | "linux-x86_64" | 859 | "linux-x86_64", |
| 860 | None | ||
| 745 | ) | 861 | ) |
| 746 | .is_err() | 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 | assert!( | 865 | assert!( |
| 750 | archive( | 866 | archive( |
| 751 | key, | 867 | key, |
| 752 | &build, | 868 | &build, |
| 753 | &signature, | 869 | &signature, |
| 754 | &version("2026-09-29-r11"), | 870 | &version("2026-09-29-r11"), |
| 755 | "linux-x86_64" | 871 | "linux-x86_64", |
| 872 | None | ||
| 756 | ) | 873 | ) |
| 757 | .is_err() | 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 | assert!( | 878 | assert!( |
| 762 | check_archive(key, &found, b"an archivf") | 879 | check_archive(key, &found, b"an archivf") |
| ... | @@ -780,6 +897,98 @@ mod tests { | ... | @@ -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 | /// An archive as `tools/release.py` packs this platform's, holding `marker`. | 992 | /// An archive as `tools/release.py` packs this platform's, holding `marker`. |
| 784 | fn pack(folder: &Path, marker: &str) -> Vec<u8> { | 993 | fn pack(folder: &Path, marker: &str) -> Vec<u8> { |
| 785 | if !cfg!(target_os = "macos") { | 994 | if !cfg!(target_os = "macos") { |
| ... | @@ -856,10 +1065,14 @@ mod tests { | ... | @@ -856,10 +1065,14 @@ mod tests { |
| 856 | let status = check(&fetch, &key, Some(&running), Some(&install), &|status| { | 1065 | let status = check(&fetch, &key, Some(&running), Some(&install), &|status| { |
| 857 | progress.lock().unwrap().push(status) | 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 | panic!("{status:?}"); | 1069 | panic!("{status:?}"); |
| 861 | }; | 1070 | }; |
| 862 | assert_eq!(found, version("2026-09-29-r10")); | 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 | assert_eq!( | 1076 | assert_eq!( |
| 864 | *progress.lock().unwrap(), | 1077 | *progress.lock().unwrap(), |
| 865 | [Status::Downloading(found.clone())] | 1078 | [Status::Downloading(found.clone())] |
| ... | @@ -869,7 +1082,10 @@ mod tests { | ... | @@ -869,7 +1082,10 @@ mod tests { |
| 869 | 1082 | ||
| 870 | fetched.lock().unwrap().clear(); | 1083 | fetched.lock().unwrap().clear(); |
| 871 | let again = check(&fetch, &key, Some(&running), Some(&install), &|_| {}); | 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 | assert!( | 1089 | assert!( |
| 874 | !fetched | 1090 | !fetched |
| 875 | .lock() | 1091 | .lock() |
| ... | @@ -884,7 +1100,7 @@ mod tests { | ... | @@ -884,7 +1100,7 @@ mod tests { |
| 884 | ); | 1100 | ); |
| 885 | assert_eq!( | 1101 | assert_eq!( |
| 886 | check(&fetch, &key, Some(&running), None, &|_| {}), | 1102 | check(&fetch, &key, Some(&running), None, &|_| {}), |
| 887 | Status::Available(found.clone()) | 1103 | Status::Available(found.clone(), listed.clone()) |
| 888 | ); | 1104 | ); |
| 889 | assert_eq!( | 1105 | assert_eq!( |
| 890 | check( | 1106 | check( |
| ... | @@ -955,6 +1171,13 @@ mod tests { | ... | @@ -955,6 +1171,13 @@ mod tests { |
| 955 | fn the_published_build_installs() { | 1171 | fn the_published_build_installs() { |
| 956 | let folder = scratch("published"); | 1172 | let folder = scratch("published"); |
| 957 | let install = staged(&folder); | 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 | let old = version("2000-01-01-r1"); | 1181 | let old = version("2000-01-01-r1"); |
| 959 | let status = check( | 1182 | let status = check( |
| 960 | &download, | 1183 | &download, |
| ... | @@ -963,7 +1186,7 @@ mod tests { | ... | @@ -963,7 +1186,7 @@ mod tests { |
| 963 | Some(&install), | 1186 | Some(&install), |
| 964 | &|_| {}, | 1187 | &|_| {}, |
| 965 | ); | 1188 | ); |
| 966 | let Status::Ready(found, staged) = status else { | 1189 | let Status::Ready(found, staged, _) = status else { |
| 967 | panic!("{status:?}"); | 1190 | panic!("{status:?}"); |
| 968 | }; | 1191 | }; |
| 969 | apply(&staged, &install).unwrap(); | 1192 | apply(&staged, &install).unwrap(); |
crates/snowbound/src/windows.rs+103-15| ... | @@ -51,6 +51,8 @@ static PREVIOUS: AtomicIsize = AtomicIsize::new(0); | ... | @@ -51,6 +51,8 @@ static PREVIOUS: AtomicIsize = AtomicIsize::new(0); |
| 51 | static COMPOSED: AtomicBool = AtomicBool::new(false); | 51 | static COMPOSED: AtomicBool = AtomicBool::new(false); |
| 52 | /// The window's scale factor, as `f32` bits, for hit-testing outside a frame. | 52 | /// The window's scale factor, as `f32` bits, for hit-testing outside a frame. |
| 53 | static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000); | 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 | /// The caption button under the pointer, by its hit-test code: as the system reports it | 56 | /// The caption button under the pointer, by its hit-test code: as the system reports it |
| 55 | /// over Mica, where the system runs them, and as the row saw it for the close button, | 57 | /// over Mica, where the system runs them, and as the row saw it for the close button, |
| 56 | /// whose glyph turns white on red. | 58 | /// whose glyph turns white on red. |
| ... | @@ -219,7 +221,7 @@ fn offer_to_open() { | ... | @@ -219,7 +221,7 @@ fn offer_to_open() { |
| 219 | None, | 221 | None, |
| 220 | &format!("\"{executable}\" \"%1\""), | 222 | &format!("\"{executable}\" \"%1\""), |
| 221 | ); | 223 | ); |
| 222 | for extension in [".one", ".onetoc2"] { | 224 | for extension in [".one", ".onetoc2", ".onepkg"] { |
| 223 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); | 225 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); |
| 224 | } | 226 | } |
| 225 | } | 227 | } |
| ... | @@ -259,6 +261,46 @@ fn reframe(hwnd: HWND) { | ... | @@ -259,6 +261,46 @@ fn reframe(hwnd: HWND) { |
| 259 | unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) }; | 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 | fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool { | 304 | fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool { |
| 263 | unsafe { | 305 | unsafe { |
| 264 | Dwm::DwmSetWindowAttribute( | 306 | Dwm::DwmSetWindowAttribute( |
| ... | @@ -292,6 +334,14 @@ unsafe extern "system" fn frame_procedure( | ... | @@ -292,6 +334,14 @@ unsafe extern "system" fn frame_procedure( |
| 292 | let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); | 334 | let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); |
| 293 | wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam) | 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 | match message { | 345 | match message { |
| 296 | // Accent colours and transparency effects reach the window only as settings. | 346 | // Accent colours and transparency effects reach the window only as settings. |
| 297 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { | 347 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { |
| ... | @@ -652,23 +702,58 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { | ... | @@ -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 | 705 | /// The theme over the window's material. Aero glass takes whatever lies behind the window |
| 656 | /// take white faces, as Internet Explorer's do there, so their text and icons stay legible, | 706 | /// and any colour the user picks, so on Windows 7 the toolbar keeps opaque faces under its |
| 657 | /// and the sidebar stays opaque. | 707 | /// icons, as `SNOWBOUND_W7_CHROME` picks: `bar`, Explorer's opaque command bar under the glass |
| 658 | pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { | 708 | /// frame; `pills`, a face per group of tools over the glass; `frost`, the strip see-through |
| 659 | let sidebar = theme.sidebar; | 709 | /// enough to tint it; or `tint`, one panel of tools in the glass's colour, and `tint-tiles`, |
| 660 | let theme = theme.over_backdrop(); | 710 | /// a tile per group. |
| 661 | if caption() != Caption::Glass || eleven() { | 711 | pub fn over_backdrop(theme: ui::Theme, appearance: Theme) -> ui::Theme { |
| 662 | return theme; | 712 | if version() >= (6, 2, 0) { |
| 713 | return theme.over_backdrop(); | ||
| 663 | } | 714 | } |
| 664 | ui::Theme { | 715 | match std::env::var("SNOWBOUND_W7_CHROME").as_deref() { |
| 665 | sidebar, | 716 | Ok("pills") => ui::Theme { |
| 666 | base: [1.0, 1.0, 1.0, 0.8], | 717 | strip: [0.0; 4], |
| 667 | tool: [1.0, 1.0, 1.0, 0.35], | 718 | tool: theme.base, |
| 668 | ..theme | 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 | /// None: the kit's own menus. | 757 | /// None: the kit's own menus. |
| 673 | pub fn menu(_: Theme) -> Option<ui::Menu> { | 758 | pub fn menu(_: Theme) -> Option<ui::Menu> { |
| 674 | None | 759 | None |
| ... | @@ -1402,7 +1487,10 @@ pub fn edit_date( | ... | @@ -1402,7 +1487,10 @@ pub fn edit_date( |
| 1402 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | 1487 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1403 | pick( | 1488 | pick( |
| 1404 | title, | 1489 | title, |
| 1405 | &[("OneNote notebooks and sections", "*.onetoc2;*.one")], | 1490 | &[( |
| 1491 | "OneNote notebooks, sections and packages", | ||
| 1492 | "*.onetoc2;*.one;*.onepkg", | ||
| 1493 | )], | ||
| 1406 | None, | 1494 | None, |
| 1407 | false, | 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,3 +252,101 @@ fn undo_walks_back_through_new_pages_and_their_titles() { |
| 252 | assert_eq!(tabs, expected, "{tree}"); | 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,9 +316,14 @@ fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ { |
| 316 | /// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest. | 316 | /// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest. |
| 317 | fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { | 317 | fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 318 | let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]); | 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 | let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); | 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 | } else { | 327 | } else { |
| 323 | sizes.fold(0.0, f32::max) | 328 | sizes.fold(0.0, f32::max) |
| 324 | } | 329 | } |
| ... | @@ -335,13 +340,7 @@ pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 { | ... | @@ -335,13 +340,7 @@ pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 { |
| 335 | !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none() | 340 | !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none() |
| 336 | }) | 341 | }) |
| 337 | .map(|child| least(nodes, *child)); | 342 | .map(|child| least(nodes, *child)); |
| 338 | let content = if along(node, 0) { | 343 | span(node, 0, sizes) + 2.0 * node.pad[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] | ||
| 345 | } | 344 | } |
| 346 | 345 | ||
| 347 | /// The least width `index` takes without clipping its children. | 346 | /// The least width `index` takes without clipping its children. |
crates/ui/src/lib.rs+23-39| ... | @@ -324,6 +324,15 @@ struct Motion { | ... | @@ -324,6 +324,15 @@ struct Motion { |
| 324 | opacity: f32, | 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 | /// What a box is this frame. Colours are linear RGBA. | 336 | /// What a box is this frame. Colours are linear RGBA. |
| 328 | #[derive(Clone, Default)] | 337 | #[derive(Clone, Default)] |
| 329 | pub struct Spec<'a> { | 338 | pub struct Spec<'a> { |
| ... | @@ -332,13 +341,13 @@ pub struct Spec<'a> { | ... | @@ -332,13 +341,13 @@ pub struct Spec<'a> { |
| 332 | /// The axis its children flow along. | 341 | /// The axis its children flow along. |
| 333 | pub axis: Axis, | 342 | pub axis: Axis, |
| 334 | pub text: Option<&'a str>, | 343 | pub text: Option<&'a str>, |
| 335 | /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. | ||
| 336 | /// The label's size in logical pixels; the theme's otherwise. | 344 | /// The label's size in logical pixels; the theme's otherwise. |
| 337 | pub font_size: Option<f32>, | 345 | pub font_size: Option<f32>, |
| 338 | /// Shapes the label semibold. | 346 | /// Shapes the label semibold. |
| 339 | pub bold: bool, | 347 | pub bold: bool, |
| 340 | /// Shapes the label italic. | 348 | /// Shapes the label italic. |
| 341 | pub italic: bool, | 349 | pub italic: bool, |
| 350 | /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. | ||
| 342 | pub font: Option<&'a str>, | 351 | pub font: Option<&'a str>, |
| 343 | pub overflow: Overflow, | 352 | pub overflow: Overflow, |
| 344 | /// The label's colour; the theme's text colour otherwise. | 353 | /// The label's colour; the theme's text colour otherwise. |
| ... | @@ -463,12 +472,7 @@ impl Primitives<'_> { | ... | @@ -463,12 +472,7 @@ impl Primitives<'_> { |
| 463 | pivot, | 472 | pivot, |
| 464 | tilt, | 473 | tilt, |
| 465 | opacity, | 474 | opacity, |
| 466 | } = self.motion.unwrap_or(Motion { | 475 | } = self.motion.unwrap_or(Motion::NONE); |
| 467 | zoom: 1.0, | ||
| 468 | pivot: [0.0; 2], | ||
| 469 | tilt: 0.0, | ||
| 470 | opacity: 1.0, | ||
| 471 | }); | ||
| 472 | let device = |point: [f32; 2]| point.map(|value| value * scale); | 476 | let device = |point: [f32; 2]| point.map(|value| value * scale); |
| 473 | let moved = |point: [f32; 2]| { | 477 | let moved = |point: [f32; 2]| { |
| 474 | device(std::array::from_fn(|axis| { | 478 | device(std::array::from_fn(|axis| { |
| ... | @@ -797,8 +801,11 @@ impl Ui { | ... | @@ -797,8 +801,11 @@ impl Ui { |
| 797 | } | 801 | } |
| 798 | 802 | ||
| 799 | /// Asks for a frame `after` this one, as a running clock does. | 803 | /// Asks for a frame `after` this one, as a running clock does. |
| 800 | pub fn wake_after(&mut self, after: std::time::Duration) { | 804 | pub fn wake_after(&mut self, after: Duration) { |
| 801 | let due = self.now + after; | 805 | self.wake_by(self.now + after); |
| 806 | } | ||
| 807 | |||
| 808 | fn wake_by(&mut self, due: Instant) { | ||
| 802 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); | 809 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); |
| 803 | } | 810 | } |
| 804 | 811 | ||
| ... | @@ -1196,7 +1203,7 @@ impl Ui { | ... | @@ -1196,7 +1203,7 @@ impl Ui { |
| 1196 | 1203 | ||
| 1197 | /// The id a box built next under the current parent would take. | 1204 | /// The id a box built next under the current parent would take. |
| 1198 | pub fn id(&self, part: impl Hash) -> Id { | 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 | /// Adds a box and makes it the parent of the boxes built until `close`. | 1209 | /// Adds a box and makes it the parent of the boxes built until `close`. |
| ... | @@ -1247,7 +1254,6 @@ impl Ui { | ... | @@ -1247,7 +1254,6 @@ impl Ui { |
| 1247 | self.signal(id) | 1254 | self.signal(id) |
| 1248 | } | 1255 | } |
| 1249 | 1256 | ||
| 1250 | /// Eases towards `target` over the next frames, starting there the first time `id` asks. | ||
| 1251 | /// Holds animated value `id` at `value` without easing, as a dragged box follows the | 1257 | /// Holds animated value `id` at `value` without easing, as a dragged box follows the |
| 1252 | /// pointer; it eases from there once animated again. | 1258 | /// pointer; it eases from there once animated again. |
| 1253 | pub fn hold(&mut self, id: Id, value: f32) -> f32 { | 1259 | pub fn hold(&mut self, id: Id, value: f32) -> f32 { |
| ... | @@ -1257,6 +1263,7 @@ impl Ui { | ... | @@ -1257,6 +1263,7 @@ impl Ui { |
| 1257 | value | 1263 | value |
| 1258 | } | 1264 | } |
| 1259 | 1265 | ||
| 1266 | /// Eases towards `target` over the next frames, starting there the first time `id` asks. | ||
| 1260 | pub fn animate(&mut self, id: Id, target: f32) -> f32 { | 1267 | pub fn animate(&mut self, id: Id, target: f32) -> f32 { |
| 1261 | let state = self.states.entry(id).or_default(); | 1268 | let state = self.states.entry(id).or_default(); |
| 1262 | state.touched = self.frame; | 1269 | state.touched = self.frame; |
| ... | @@ -1293,7 +1300,7 @@ impl Ui { | ... | @@ -1293,7 +1300,7 @@ impl Ui { |
| 1293 | /// The size of `text` as a label, in logical pixels. | 1300 | /// The size of `text` as a label, in logical pixels. |
| 1294 | pub fn measure(&mut self, text: &str) -> [f32; 2] { | 1301 | pub fn measure(&mut self, text: &str) -> [f32; 2] { |
| 1295 | self.texts | 1302 | self.texts |
| 1296 | .label(text, self.theme.font_size, false, None, self.frame) | 1303 | .label(text, self.theme.font_size, self.frame) |
| 1297 | .size | 1304 | .size |
| 1298 | } | 1305 | } |
| 1299 | 1306 | ||
| ... | @@ -1316,8 +1323,7 @@ impl Ui { | ... | @@ -1316,8 +1323,7 @@ impl Ui { |
| 1316 | self.caret = Some((id, start, self.frame)); | 1323 | self.caret = Some((id, start, self.frame)); |
| 1317 | let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); | 1324 | let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); |
| 1318 | if let Some(hold) = hold { | 1325 | if let Some(hold) = hold { |
| 1319 | let due = self.now + hold; | 1326 | self.wake_after(hold); |
| 1320 | self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); | ||
| 1321 | } | 1327 | } |
| 1322 | opacity | 1328 | opacity |
| 1323 | } | 1329 | } |
| ... | @@ -1468,10 +1474,8 @@ impl Ui { | ... | @@ -1468,10 +1474,8 @@ impl Ui { |
| 1468 | { | 1474 | { |
| 1469 | let opacity = self.progress(due, TIP_FADE); | 1475 | let opacity = self.progress(due, TIP_FADE); |
| 1470 | self.nodes[index].motion = Some(Motion { | 1476 | self.nodes[index].motion = Some(Motion { |
| 1471 | zoom: 1.0, | ||
| 1472 | pivot: [0.0; 2], | ||
| 1473 | tilt: 0.0, | ||
| 1474 | opacity, | 1477 | opacity, |
| 1478 | ..Motion::NONE | ||
| 1475 | }); | 1479 | }); |
| 1476 | self.paint(index, None, None); | 1480 | self.paint(index, None, None); |
| 1477 | } | 1481 | } |
| ... | @@ -1961,28 +1965,8 @@ impl Ui { | ... | @@ -1961,28 +1965,8 @@ impl Ui { |
| 1961 | layers | 1965 | layers |
| 1962 | } | 1966 | } |
| 1963 | 1967 | ||
| 1964 | pub(crate) fn texts(&mut self) -> (&mut Texts, u64) { | 1968 | pub(crate) fn state(&mut self, id: Id) -> &mut State { |
| 1965 | (&mut self.texts, self.frame) | 1969 | self.states.entry(id).or_default() |
| 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 | ||
| 1986 | } | 1970 | } |
| 1987 | } | 1971 | } |
| 1988 | 1972 |
crates/ui/src/popup.rs+55-96| ... | @@ -86,6 +86,13 @@ impl Item<'_> { | ... | @@ -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 | /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a | 96 | /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a |
| 90 | /// filter field showing `filter` while empty when given. Returns the index of the item | 97 | /// filter field showing `filter` while empty when given. Returns the index of the item |
| 91 | /// chosen. | 98 | /// chosen. |
| ... | @@ -100,11 +107,7 @@ pub fn menu( | ... | @@ -100,11 +107,7 @@ pub fn menu( |
| 100 | return None; | 107 | return None; |
| 101 | } | 108 | } |
| 102 | let style = ui.theme.menu(); | 109 | let style = ui.theme.menu(); |
| 103 | let mut measure = |text| { | 110 | let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0]; |
| 104 | ui.texts | ||
| 105 | .label(text, style.font_size, false, None, ui.frame) | ||
| 106 | .size[0] | ||
| 107 | }; | ||
| 108 | let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { | 111 | let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { |
| 109 | [ | 112 | [ |
| 110 | text.max( | 113 | text.max( |
| ... | @@ -118,15 +121,12 @@ pub fn menu( | ... | @@ -118,15 +121,12 @@ pub fn menu( |
| 118 | shortcut.max(measure(item.trailing())), | 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 | let width = | 124 | let width = |
| 125 | text + if shortcut > 0.0 { | 125 | text + if shortcut > 0.0 { |
| 126 | 3.0 * ICON_GAP + shortcut | 126 | 3.0 * ICON_GAP + shortcut |
| 127 | } else { | 127 | } else { |
| 128 | 0.0 | 128 | 0.0 |
| 129 | } + if icons { ICON + ICON_GAP } else { 0.0 } | 129 | } + if icons(items) { ICON + ICON_GAP } else { 0.0 } |
| 130 | + if items.len() as f32 > ROWS { | 130 | + if items.len() as f32 > ROWS { |
| 131 | GUTTER | 131 | GUTTER |
| 132 | } else { | 132 | } else { |
| ... | @@ -189,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> | ... | @@ -189,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> |
| 189 | return; | 189 | return; |
| 190 | }; | 190 | }; |
| 191 | if now < due { | 191 | if now < due { |
| 192 | ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); | 192 | ui.wake_by(due); |
| 193 | return; | 193 | return; |
| 194 | } | 194 | } |
| 195 | popup.submenu = Some((key, None)); | 195 | popup.submenu = Some((key, None)); |
| ... | @@ -283,7 +283,7 @@ fn tooltip_below( | ... | @@ -283,7 +283,7 @@ fn tooltip_below( |
| 283 | return; | 283 | return; |
| 284 | }; | 284 | }; |
| 285 | if now < due { | 285 | if now < due { |
| 286 | ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); | 286 | ui.wake_by(due); |
| 287 | return; | 287 | return; |
| 288 | } | 288 | } |
| 289 | let title = if keys.is_empty() { | 289 | let title = if keys.is_empty() { |
| ... | @@ -723,34 +723,8 @@ pub fn colors( | ... | @@ -723,34 +723,8 @@ pub fn colors( |
| 723 | // The button is cell 0 and the swatches follow it. | 723 | // The button is cell 0 and the swatches follow it. |
| 724 | let cell = |index: usize| id.child(("cell", index)); | 724 | let cell = |index: usize| id.child(("cell", index)); |
| 725 | let count = swatches.len() + 1; | 725 | let count = swatches.len() + 1; |
| 726 | let keys = navigation( | 726 | let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| { |
| 727 | ui, | 727 | match (key, highlight) { |
| 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 | } | ||
| 754 | (_, None) => 0, | 728 | (_, None) => 0, |
| 755 | (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), | 729 | (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), |
| 756 | (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), | 730 | (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), |
| ... | @@ -758,8 +732,8 @@ pub fn colors( | ... | @@ -758,8 +732,8 @@ pub fn colors( |
| 758 | (_, Some(0)) => 1, | 732 | (_, Some(0)) => 1, |
| 759 | (_, Some(at)) if at + columns < count => at + columns, | 733 | (_, Some(at)) if at + columns < count => at + columns, |
| 760 | (_, Some(at)) => at, | 734 | (_, Some(at)) => at, |
| 761 | }); | 735 | } |
| 762 | } | 736 | }); |
| 763 | if let Some(index) = chosen { | 737 | if let Some(index) = chosen { |
| 764 | ui.close_popup(id); | 738 | ui.close_popup(id); |
| 765 | return Some((index > 0).then(|| swatches[index - 1].0)); | 739 | return Some((index > 0).then(|| swatches[index - 1].0)); |
| ... | @@ -834,7 +808,6 @@ pub fn colors( | ... | @@ -834,7 +808,6 @@ pub fn colors( |
| 834 | } | 808 | } |
| 835 | highlighted(ui, id, highlight.map(cell)); | 809 | highlighted(ui, id, highlight.map(cell)); |
| 836 | ui.close(); | 810 | ui.close(); |
| 837 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | ||
| 838 | None | 811 | None |
| 839 | } | 812 | } |
| 840 | 813 | ||
| ... | @@ -845,14 +818,15 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) { | ... | @@ -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, | 821 | /// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight, |
| 849 | /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the | 822 | /// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose. |
| 850 | /// columns and rows chosen. | 823 | fn pick_cell( |
| 851 | pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> { | 824 | ui: &mut Ui, |
| 852 | if !ui.popup_open(id) { | 825 | id: Id, |
| 853 | return None; | 826 | count: usize, |
| 854 | } | 827 | cell: impl Fn(usize) -> Id, |
| 855 | let cell = |index: usize| id.child(("cell", index)); | 828 | step: impl Fn(NamedKey, Option<usize>) -> usize, |
| 829 | ) -> (Option<usize>, Option<usize>) { | ||
| 856 | let keys = navigation( | 830 | let keys = navigation( |
| 857 | ui, | 831 | ui, |
| 858 | &[id], | 832 | &[id], |
| ... | @@ -864,10 +838,9 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op | ... | @@ -864,10 +838,9 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 864 | NamedKey::Enter, | 838 | NamedKey::Enter, |
| 865 | ], | 839 | ], |
| 866 | ); | 840 | ); |
| 867 | let [columns, rows] = size; | ||
| 868 | let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); | 841 | let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); |
| 869 | let mut chosen = None; | 842 | let mut chosen = None; |
| 870 | for index in 0..columns * rows { | 843 | for index in 0..count { |
| 871 | let signal = ui.signal(cell(index)); | 844 | let signal = ui.signal(cell(index)); |
| 872 | if signal.hovered && ui.moved { | 845 | if signal.hovered && ui.moved { |
| 873 | highlight = Some(index); | 846 | highlight = Some(index); |
| ... | @@ -877,20 +850,36 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op | ... | @@ -877,20 +850,36 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 877 | } | 850 | } |
| 878 | } | 851 | } |
| 879 | for key in keys { | 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 | let [column, row] = [at % columns, at / columns]; | 875 | let [column, row] = [at % columns, at / columns]; |
| 882 | highlight = Some(match key { | 876 | match key { |
| 883 | NamedKey::Enter => { | ||
| 884 | chosen = chosen.or(highlight); | ||
| 885 | continue; | ||
| 886 | } | ||
| 887 | _ if highlight.is_none() => 0, | ||
| 888 | NamedKey::ArrowLeft => at - usize::from(column > 0), | 877 | NamedKey::ArrowLeft => at - usize::from(column > 0), |
| 889 | NamedKey::ArrowRight => at + usize::from(column + 1 < columns), | 878 | NamedKey::ArrowRight => at + usize::from(column + 1 < columns), |
| 890 | NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 }, | 879 | NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 }, |
| 891 | _ => at + if row + 1 < rows { columns } else { 0 }, | 880 | _ => at + if row + 1 < rows { columns } else { 0 }, |
| 892 | }); | 881 | } |
| 893 | } | 882 | }); |
| 894 | let extent = |index: usize| [index % columns + 1, index / columns + 1]; | 883 | let extent = |index: usize| [index % columns + 1, index / columns + 1]; |
| 895 | if let Some(index) = chosen { | 884 | if let Some(index) = chosen { |
| 896 | ui.close_popup(id); | 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,7 +960,6 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 971 | ui.close(); | 960 | ui.close(); |
| 972 | highlighted(ui, id, highlight.map(cell)); | 961 | highlighted(ui, id, highlight.map(cell)); |
| 973 | ui.close(); | 962 | ui.close(); |
| 974 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | ||
| 975 | None | 963 | None |
| 976 | } | 964 | } |
| 977 | 965 | ||
| ... | @@ -1011,41 +999,15 @@ pub fn gallery( | ... | @@ -1011,41 +999,15 @@ pub fn gallery( |
| 1011 | }) | 999 | }) |
| 1012 | .map_or(1, |group| group.columns) | 1000 | .map_or(1, |group| group.columns) |
| 1013 | }; | 1001 | }; |
| 1014 | let keys = navigation( | 1002 | let (highlight, chosen) = pick_cell(ui, id, count, cell_id, |key, highlight| { |
| 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 { | ||
| 1037 | let at = highlight.or(current.first().copied()).unwrap_or(0); | 1003 | let at = highlight.or(current.first().copied()).unwrap_or(0); |
| 1038 | highlight = Some(match key { | 1004 | match key { |
| 1039 | NamedKey::Enter => { | ||
| 1040 | chosen = chosen.or(highlight); | ||
| 1041 | continue; | ||
| 1042 | } | ||
| 1043 | NamedKey::ArrowLeft => at.saturating_sub(1), | 1005 | NamedKey::ArrowLeft => at.saturating_sub(1), |
| 1044 | NamedKey::ArrowRight => (at + 1).min(count - 1), | 1006 | NamedKey::ArrowRight => (at + 1).min(count - 1), |
| 1045 | NamedKey::ArrowUp => at.saturating_sub(columns(at)), | 1007 | NamedKey::ArrowUp => at.saturating_sub(columns(at)), |
| 1046 | _ => (at + columns(at)).min(count - 1), | 1008 | _ => (at + columns(at)).min(count - 1), |
| 1047 | }); | 1009 | } |
| 1048 | } | 1010 | }); |
| 1049 | if let Some(index) = chosen { | 1011 | if let Some(index) = chosen { |
| 1050 | ui.close_popup(id); | 1012 | ui.close_popup(id); |
| 1051 | return Some(index); | 1013 | return Some(index); |
| ... | @@ -1113,7 +1075,6 @@ pub fn gallery( | ... | @@ -1113,7 +1075,6 @@ pub fn gallery( |
| 1113 | } | 1075 | } |
| 1114 | highlighted(ui, id, highlight.map(cell_id)); | 1076 | highlighted(ui, id, highlight.map(cell_id)); |
| 1115 | ui.close(); | 1077 | ui.close(); |
| 1116 | state(ui, id).highlight = highlight.map(|cell| cell as u64); | ||
| 1117 | None | 1078 | None |
| 1118 | } | 1079 | } |
| 1119 | 1080 | ||
| ... | @@ -1488,9 +1449,7 @@ impl<'a> Matches<'a> { | ... | @@ -1488,9 +1449,7 @@ impl<'a> Matches<'a> { |
| 1488 | rules, | 1449 | rules, |
| 1489 | ruled, | 1450 | ruled, |
| 1490 | rule, | 1451 | rule, |
| 1491 | icons: items | 1452 | icons: icons(items), |
| 1492 | .iter() | ||
| 1493 | .any(|item| item.icon.is_some() || item.checked == Some(true)), | ||
| 1494 | } | 1453 | } |
| 1495 | } | 1454 | } |
| 1496 | } | 1455 | } |
crates/ui/src/shell.rs+15-9| ... | @@ -34,12 +34,12 @@ const EDGE: f32 = 20.0; | ... | @@ -34,12 +34,12 @@ const EDGE: f32 = 20.0; |
| 34 | /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. | 34 | /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. |
| 35 | /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the | 35 | /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the |
| 36 | /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it | 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 | #[allow(clippy::too_many_arguments)] | 38 | #[allow(clippy::too_many_arguments)] |
| 39 | pub fn section_tabs( | 39 | pub fn section_tabs( |
| 40 | ui: &mut Ui, | 40 | ui: &mut Ui, |
| 41 | row: Id, | 41 | row: Id, |
| 42 | tabs: &[(&str, [f32; 4])], | 42 | tabs: &[(&str, [f32; 4], bool)], |
| 43 | active: usize, | 43 | active: usize, |
| 44 | lit: Option<usize>, | 44 | lit: Option<usize>, |
| 45 | dragged: Option<Dragged>, | 45 | dragged: Option<Dragged>, |
| ... | @@ -52,8 +52,11 @@ pub fn section_tabs( | ... | @@ -52,8 +52,11 @@ pub fn section_tabs( |
| 52 | let mut left = crate::SHADOW[0]; | 52 | let mut left = crate::SHADOW[0]; |
| 53 | let placed: Vec<_> = tabs | 53 | let placed: Vec<_> = tabs |
| 54 | .iter() | 54 | .iter() |
| 55 | .map(|(name, _)| { | 55 | .map(|&(name, _, bold)| { |
| 56 | let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height); | 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 | left += width; | 60 | left += width; |
| 58 | (left - width, width) | 61 | (left - width, width) |
| 59 | }) | 62 | }) |
| ... | @@ -129,7 +132,7 @@ pub fn section_tabs( | ... | @@ -129,7 +132,7 @@ pub fn section_tabs( |
| 129 | .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) | 132 | .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) |
| 130 | .chain(lifted); | 133 | .chain(lifted); |
| 131 | for index in order { | 134 | for index in order { |
| 132 | let (name, color) = tabs[index]; | 135 | let (name, color, bold) = tabs[index]; |
| 133 | let (x, width) = placed[index]; | 136 | let (x, width) = placed[index]; |
| 134 | // Keyed by name, so a tab eases from where it stood when the tabs are reordered. | 137 | // Keyed by name, so a tab eases from where it stood when the tabs are reordered. |
| 135 | let key = ui.id(("x", name)); | 138 | let key = ui.id(("x", name)); |
| ... | @@ -164,6 +167,7 @@ pub fn section_tabs( | ... | @@ -164,6 +167,7 @@ pub fn section_tabs( |
| 164 | size: [px(width), px(tall)], | 167 | size: [px(width), px(tall)], |
| 165 | position: [x - offset, height - tall], | 168 | position: [x - offset, height - tall], |
| 166 | text: Some(name), | 169 | text: Some(name), |
| 170 | bold, | ||
| 167 | color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), | 171 | color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), |
| 168 | fill: Some(if lit == Some(index) { | 172 | fill: Some(if lit == Some(index) { |
| 169 | mix(colors.frame[0], [1.0; 4], 0.08) | 173 | mix(colors.frame[0], [1.0; 4], 0.08) |
| ... | @@ -306,6 +310,11 @@ fn opens(ui: &mut Ui, id: Id, menu: Id) { | ... | @@ -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 | /// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon` | 318 | /// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon` |
| 310 | /// tinted by `tint` shows faded, and it takes no clicks. | 319 | /// tinted by `tint` shows faded, and it takes no clicks. |
| 311 | pub fn unavailable( | 320 | pub fn unavailable( |
| ... | @@ -315,7 +324,6 @@ pub fn unavailable( | ... | @@ -315,7 +324,6 @@ pub fn unavailable( |
| 315 | tint: [f32; 4], | 324 | tint: [f32; 4], |
| 316 | menu: bool, | 325 | menu: bool, |
| 317 | ) { | 326 | ) { |
| 318 | let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35]; | ||
| 319 | let arrow = faded(ui.theme.text_dim); | 327 | let arrow = faded(ui.theme.text_dim); |
| 320 | let id = ui.open( | 328 | let id = ui.open( |
| 321 | part, | 329 | part, |
| ... | @@ -530,9 +538,7 @@ pub fn combo( | ... | @@ -530,9 +538,7 @@ pub fn combo( |
| 530 | enabled: bool, | 538 | enabled: bool, |
| 531 | ) { | 539 | ) { |
| 532 | let theme = ui.theme.clone(); | 540 | let theme = ui.theme.clone(); |
| 533 | let faded = |[red, green, blue, alpha]: [f32; 4]| { | 541 | let faded = |color| if enabled { color } else { faded(color) }; |
| 534 | [red, green, blue, if enabled { alpha } else { alpha * 0.35 }] | ||
| 535 | }; | ||
| 536 | let id = ui.open( | 542 | let id = ui.open( |
| 537 | part, | 543 | part, |
| 538 | Spec { | 544 | Spec { |
crates/ui/src/tests.rs+3-3| ... | @@ -605,8 +605,8 @@ fn focused_field(text: &str) -> (Ui, String) { | ... | @@ -605,8 +605,8 @@ fn focused_field(text: &str) -> (Ui, String) { |
| 605 | /// Moves the pointer over the caret before byte `index` of the field's `text`. | 605 | /// Moves the pointer over the caret before byte `index` of the field's `text`. |
| 606 | fn point_to(ui: &mut Ui, text: &str, index: usize) { | 606 | fn point_to(ui: &mut Ui, text: &str, index: usize) { |
| 607 | let size = ui.theme.font_size; | 607 | let size = ui.theme.font_size; |
| 608 | let (texts, frame) = ui.texts(); | 608 | let (texts, frame) = (&mut ui.texts, ui.frame); |
| 609 | let layout = &texts.label(text, size, false, None, frame).layout; | 609 | let layout = &texts.label(text, size, frame).layout; |
| 610 | let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) | 610 | let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) |
| 611 | .geometry(layout, 1.0) | 611 | .geometry(layout, 1.0) |
| 612 | .x0 as f32; | 612 | .x0 as f32; |
| ... | @@ -2817,7 +2817,7 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id { | ... | @@ -2817,7 +2817,7 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id { |
| 2817 | ]; | 2817 | ]; |
| 2818 | let tabs: Vec<_> = names | 2818 | let tabs: Vec<_> = names |
| 2819 | .iter() | 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 | .collect(); | 2821 | .collect(); |
| 2822 | let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); | 2822 | let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); |
| 2823 | let row = Id::ROOT.child("tabs"); | 2823 | let row = Id::ROOT.child("tabs"); |
crates/ui/src/text.rs+4-11| ... | @@ -24,19 +24,12 @@ pub(crate) struct Texts { | ... | @@ -24,19 +24,12 @@ pub(crate) struct Texts { |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | impl Texts { | 26 | impl Texts { |
| 27 | /// `size` is in logical pixels; `font` names a family to prefer over the interface's. | 27 | /// `text` in the interface's regular face, `size` logical pixels. |
| 28 | pub fn label( | 28 | pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> { |
| 29 | &mut self, | 29 | self.styled(text, size, false, false, None, frame) |
| 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) | ||
| 37 | } | 30 | } |
| 38 | 31 | ||
| 39 | /// `label`, slanted where `italic`. | 32 | /// `font` names a family to prefer over the interface's. |
| 40 | pub fn styled( | 33 | pub fn styled( |
| 41 | &mut self, | 34 | &mut self, |
| 42 | text: &str, | 35 | text: &str, |
crates/ui/src/theme.rs+7-1| ... | @@ -13,8 +13,12 @@ pub struct Theme { | ... | @@ -13,8 +13,12 @@ pub struct Theme { |
| 13 | pub strip: [f32; 4], | 13 | pub strip: [f32; 4], |
| 14 | /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop. | 14 | /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop. |
| 15 | pub sidebar: [f32; 4], | 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 | pub tool: [f32; 4], | 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 | pub accent: [f32; 4], | 22 | pub accent: [f32; 4], |
| 19 | /// The text caret, and selected text's fill with and without keyboard focus; the | 23 | /// The text caret, and selected text's fill with and without keyboard focus; the |
| 20 | /// platform's own where it has them. | 24 | /// platform's own where it has them. |
| ... | @@ -145,6 +149,7 @@ impl Theme { | ... | @@ -145,6 +149,7 @@ impl Theme { |
| 145 | strip: srgb(0x27, 0x2a, 0x2b), | 149 | strip: srgb(0x27, 0x2a, 0x2b), |
| 146 | sidebar: srgb(0x27, 0x2a, 0x2b), | 150 | sidebar: srgb(0x27, 0x2a, 0x2b), |
| 147 | tool: [0.0; 4], | 151 | tool: [0.0; 4], |
| 152 | tool_panel: false, | ||
| 148 | accent: srgb(0x00, 0x79, 0xa6), | 153 | accent: srgb(0x00, 0x79, 0xa6), |
| 149 | caret, | 154 | caret, |
| 150 | selection, | 155 | selection, |
| ... | @@ -176,6 +181,7 @@ impl Theme { | ... | @@ -176,6 +181,7 @@ impl Theme { |
| 176 | strip: srgb(0xeb, 0xed, 0xf0), | 181 | strip: srgb(0xeb, 0xed, 0xf0), |
| 177 | sidebar: srgb(0xeb, 0xed, 0xf0), | 182 | sidebar: srgb(0xeb, 0xed, 0xf0), |
| 178 | tool: [0.0; 4], | 183 | tool: [0.0; 4], |
| 184 | tool_panel: false, | ||
| 179 | accent: srgb(0x00, 0x79, 0xa6), | 185 | accent: srgb(0x00, 0x79, 0xa6), |
| 180 | caret, | 186 | caret, |
| 181 | selection, | 187 | selection, |
crates/ui/src/widgets.rs+18-22| ... | @@ -162,11 +162,11 @@ pub fn scrollbar( | ... | @@ -162,11 +162,11 @@ pub fn scrollbar( |
| 162 | if signal.pressed | 162 | if signal.pressed |
| 163 | && let Some(pointer) = pointer | 163 | && let Some(pointer) = pointer |
| 164 | { | 164 | { |
| 165 | *ui.grab(id) = pointer - start; | 165 | ui.state(id).grab = pointer - start; |
| 166 | } | 166 | } |
| 167 | let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| { | 167 | let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| { |
| 168 | let travel = (track - size).max(f32::EPSILON); | 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 | range[0] + fraction * span | 170 | range[0] + fraction * span |
| 171 | }); | 171 | }); |
| 172 | let mut position = [0.0; 2]; | 172 | let mut position = [0.0; 2]; |
| ... | @@ -212,7 +212,7 @@ fn system_scrollbar( | ... | @@ -212,7 +212,7 @@ fn system_scrollbar( |
| 212 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); | 212 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); |
| 213 | let now = ui.now; | 213 | let now = ui.now; |
| 214 | if !signal.pressed && !signal.dragging { | 214 | if !signal.pressed && !signal.dragging { |
| 215 | *ui.held(id) = None; | 215 | ui.state(id).held = None; |
| 216 | } | 216 | } |
| 217 | let request = Scroller { | 217 | let request = Scroller { |
| 218 | axis, | 218 | axis, |
| ... | @@ -220,7 +220,7 @@ fn system_scrollbar( | ... | @@ -220,7 +220,7 @@ fn system_scrollbar( |
| 220 | scale: ui.scale, | 220 | scale: ui.scale, |
| 221 | value: ((offset - range[0]) / span).clamp(0.0, 1.0), | 221 | value: ((offset - range[0]) / span).clamp(0.0, 1.0), |
| 222 | proportion: (view / (view + span)).clamp(0.0, 1.0), | 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 | active: ui.window_focused, | 224 | active: ui.window_focused, |
| 225 | }; | 225 | }; |
| 226 | let painted = (ui.scrollers.as_mut()?.paint)(&request); | 226 | let painted = (ui.scrollers.as_mut()?.paint)(&request); |
| ... | @@ -239,15 +239,15 @@ fn system_scrollbar( | ... | @@ -239,15 +239,15 @@ fn system_scrollbar( |
| 239 | ] | 239 | ] |
| 240 | .into_iter() | 240 | .into_iter() |
| 241 | .find(|(_, extent)| within(*extent, at)); | 241 | .find(|(_, extent)| within(*extent, at)); |
| 242 | *ui.held(id) = hit.map(|(part, _)| (part, now)); | 242 | ui.state(id).held = hit.map(|(part, _)| (part, now)); |
| 243 | *ui.grab(id) = at - painted.knob[0]; | 243 | ui.state(id).grab = at - painted.knob[0]; |
| 244 | } | 244 | } |
| 245 | match *ui.held(id) { | 245 | match ui.state(id).held { |
| 246 | Some((ScrollerPart::Knob, _)) => { | 246 | Some((ScrollerPart::Knob, _)) => { |
| 247 | let travel = | 247 | let travel = |
| 248 | (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]); | 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)) | 249 | let from = at - ui.state(id).grab - painted.slot[0]; |
| 250 | .clamp(0.0, 1.0); | 250 | let fraction = (from / travel.max(f32::EPSILON)).clamp(0.0, 1.0); |
| 251 | chosen = Some(range[0] + fraction * span); | 251 | chosen = Some(range[0] + fraction * span); |
| 252 | } | 252 | } |
| 253 | Some((part, due)) if now >= due => { | 253 | Some((part, due)) if now >= due => { |
| ... | @@ -260,7 +260,7 @@ fn system_scrollbar( | ... | @@ -260,7 +260,7 @@ fn system_scrollbar( |
| 260 | _ => view - line, | 260 | _ => view - line, |
| 261 | }; | 261 | }; |
| 262 | let delay = if signal.pressed { REPEAT_DELAY } else { REPEAT }; | 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 | chosen = Some((offset + step).clamp(range[0], range[1])); | 264 | chosen = Some((offset + step).clamp(range[0], range[1])); |
| 265 | } | 265 | } |
| 266 | _ => {} | 266 | _ => {} |
| ... | @@ -360,13 +360,11 @@ fn field( | ... | @@ -360,13 +360,11 @@ fn field( |
| 360 | .zip(ui.rect(id)) | 360 | .zip(ui.rect(id)) |
| 361 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) | 361 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) |
| 362 | .filter(|_| signal.pressed || signal.dragging); | 362 | .filter(|_| signal.pressed || signal.dragging); |
| 363 | let (mut selection, mut press, select) = { | 363 | let state = ui.state(id); |
| 364 | let (selection, press, select) = ui.field(id); | 364 | let (mut selection, mut press, select) = (state.selection, state.press, state.select.take()); |
| 365 | (*selection, *press, select.take()) | ||
| 366 | }; | ||
| 367 | let before = [selection.anchor(), selection.focus()]; | 365 | let before = [selection.anchor(), selection.focus()]; |
| 368 | let (texts, frame) = ui.texts(); | 366 | let (texts, frame) = (&mut ui.texts, ui.frame); |
| 369 | let mut label = texts.label(&shown_text, size, false, None, frame); | 367 | let mut label = texts.label(&shown_text, size, frame); |
| 370 | if let Some(x) = pointer { | 368 | if let Some(x) = pointer { |
| 371 | let layout = &label.layout; | 369 | let layout = &label.layout; |
| 372 | let unit = if signal.pressed { signal.unit } else { press.1 }; | 370 | let unit = if signal.pressed { signal.unit } else { press.1 }; |
| ... | @@ -470,7 +468,7 @@ fn field( | ... | @@ -470,7 +468,7 @@ fn field( |
| 470 | let range = to_text(text, range.start)..to_text(text, range.end); | 468 | let range = to_text(text, range.start)..to_text(text, range.end); |
| 471 | text.replace_range(range.clone(), inserted); | 469 | text.replace_range(range.clone(), inserted); |
| 472 | shown_text = masked(text); | 470 | shown_text = masked(text); |
| 473 | label = texts.label(&shown_text, size, false, None, frame); | 471 | label = texts.label(&shown_text, size, frame); |
| 474 | selection = Selection::from_byte_index( | 472 | selection = Selection::from_byte_index( |
| 475 | &label.layout, | 473 | &label.layout, |
| 476 | to_shown(text, range.start + inserted.len()), | 474 | to_shown(text, range.start + inserted.len()), |
| ... | @@ -478,11 +476,9 @@ fn field( | ... | @@ -478,11 +476,9 @@ fn field( |
| 478 | ); | 476 | ); |
| 479 | } | 477 | } |
| 480 | let moved = signal.pressed || before != [selection.anchor(), selection.focus()]; | 478 | let moved = signal.pressed || before != [selection.anchor(), selection.focus()]; |
| 481 | { | 479 | let state = ui.state(id); |
| 482 | let (stored, stored_press, _) = ui.field(id); | 480 | state.selection = selection; |
| 483 | *stored = selection; | 481 | state.press = press; |
| 484 | *stored_press = press; | ||
| 485 | } | ||
| 486 | let theme = ui.theme.clone(); | 482 | let theme = ui.theme.clone(); |
| 487 | let shown = if text.is_empty() { | 483 | let shown = if text.is_empty() { |
| 488 | placeholder | 484 | placeholder |
fuzz/Cargo.lock+240| ... | @@ -2,6 +2,22 @@ | ... | @@ -2,6 +2,22 @@ |
| 2 | # It is not intended for manual editing. | 2 | # It is not intended for manual editing. |
| 3 | version = 4 | 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 | [[package]] | 21 | [[package]] |
| 6 | name = "aes" | 22 | name = "aes" |
| 7 | version = "0.9.3" | 23 | version = "0.9.3" |
| ... | @@ -14,6 +30,20 @@ dependencies = [ | ... | @@ -14,6 +30,20 @@ dependencies = [ |
| 14 | "zeroize", | 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 | [[package]] | 47 | [[package]] |
| 18 | name = "arbitrary" | 48 | name = "arbitrary" |
| 19 | version = "1.4.2" | 49 | version = "1.4.2" |
| ... | @@ -77,6 +107,24 @@ dependencies = [ | ... | @@ -77,6 +107,24 @@ dependencies = [ |
| 77 | "syn 3.0.5", | 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 | [[package]] | 128 | [[package]] |
| 81 | name = "canvas" | 129 | name = "canvas" |
| 82 | version = "0.1.0" | 130 | version = "0.1.0" |
| ... | @@ -135,6 +183,12 @@ dependencies = [ | ... | @@ -135,6 +183,12 @@ dependencies = [ |
| 135 | "zeroize", | 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 | [[package]] | 192 | [[package]] |
| 139 | name = "const-oid" | 193 | name = "const-oid" |
| 140 | version = "0.10.2" | 194 | version = "0.10.2" |
| ... | @@ -156,15 +210,50 @@ dependencies = [ | ... | @@ -156,15 +210,50 @@ dependencies = [ |
| 156 | "libc", | 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 | [[package]] | 222 | [[package]] |
| 160 | name = "crypto-common" | 223 | name = "crypto-common" |
| 161 | version = "0.2.2" | 224 | version = "0.2.2" |
| 162 | source = "registry+https://github.com/rust-lang/crates.io-index" | 225 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 163 | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" | 226 | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" |
| 164 | dependencies = [ | 227 | dependencies = [ |
| 228 | "getrandom", | ||
| 165 | "hybrid-array", | 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 | [[package]] | 257 | [[package]] |
| 169 | name = "digest" | 258 | name = "digest" |
| 170 | version = "0.11.3" | 259 | version = "0.11.3" |
| ... | @@ -174,6 +263,7 @@ dependencies = [ | ... | @@ -174,6 +263,7 @@ dependencies = [ |
| 174 | "block-buffer", | 263 | "block-buffer", |
| 175 | "const-oid", | 264 | "const-oid", |
| 176 | "crypto-common", | 265 | "crypto-common", |
| 266 | "ctutils", | ||
| 177 | "zeroize", | 267 | "zeroize", |
| 178 | ] | 268 | ] |
| 179 | 269 | ||
| ... | @@ -232,12 +322,32 @@ version = "2.5.0" | ... | @@ -232,12 +322,32 @@ version = "2.5.0" |
| 232 | source = "registry+https://github.com/rust-lang/crates.io-index" | 322 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 233 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | 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 | [[package]] | 335 | [[package]] |
| 236 | name = "find-msvc-tools" | 336 | name = "find-msvc-tools" |
| 237 | version = "0.1.12" | 337 | version = "0.1.12" |
| 238 | source = "registry+https://github.com/rust-lang/crates.io-index" | 338 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 239 | checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" | 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 | [[package]] | 351 | [[package]] |
| 242 | name = "foldhash" | 352 | name = "foldhash" |
| 243 | version = "0.2.0" | 353 | version = "0.2.0" |
| ... | @@ -283,6 +393,16 @@ dependencies = [ | ... | @@ -283,6 +393,16 @@ dependencies = [ |
| 283 | "cfg-if", | 393 | "cfg-if", |
| 284 | "libc", | 394 | "libc", |
| 285 | "r-efi", | 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 | [[package]] | 408 | [[package]] |
| ... | @@ -324,6 +444,15 @@ dependencies = [ | ... | @@ -324,6 +444,15 @@ dependencies = [ |
| 324 | "hashbrown 0.17.1", | 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 | [[package]] | 456 | [[package]] |
| 328 | name = "hybrid-array" | 457 | name = "hybrid-array" |
| 329 | version = "0.4.14" | 458 | version = "0.4.14" |
| ... | @@ -556,6 +685,12 @@ version = "0.8.3" | ... | @@ -556,6 +685,12 @@ version = "0.8.3" |
| 556 | source = "registry+https://github.com/rust-lang/crates.io-index" | 685 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 557 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | 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 | [[package]] | 694 | [[package]] |
| 560 | name = "md5" | 695 | name = "md5" |
| 561 | version = "0.8.1" | 696 | version = "0.8.1" |
| ... | @@ -577,6 +712,16 @@ dependencies = [ | ... | @@ -577,6 +712,16 @@ dependencies = [ |
| 577 | "libc", | 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 | [[package]] | 725 | [[package]] |
| 581 | name = "nix" | 726 | name = "nix" |
| 582 | version = "0.31.3" | 727 | version = "0.31.3" |
| ... | @@ -593,6 +738,12 @@ dependencies = [ | ... | @@ -593,6 +738,12 @@ dependencies = [ |
| 593 | name = "notebook" | 738 | name = "notebook" |
| 594 | version = "0.1.0" | 739 | version = "0.1.0" |
| 595 | dependencies = [ | 740 | dependencies = [ |
| 741 | "aes-gcm", | ||
| 742 | "base64", | ||
| 743 | "cab", | ||
| 744 | "getrandom", | ||
| 745 | "hmac", | ||
| 746 | "nix", | ||
| 596 | "onestore", | 747 | "onestore", |
| 597 | "rusqlite", | 748 | "rusqlite", |
| 598 | "serde", | 749 | "serde", |
| ... | @@ -600,8 +751,16 @@ dependencies = [ | ... | @@ -600,8 +751,16 @@ dependencies = [ |
| 600 | "sha2", | 751 | "sha2", |
| 601 | "tempfile", | 752 | "tempfile", |
| 602 | "thiserror", | 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 | [[package]] | 764 | [[package]] |
| 606 | name = "objc2" | 765 | name = "objc2" |
| 607 | version = "0.6.4" | 766 | version = "0.6.4" |
| ... | @@ -660,6 +819,7 @@ dependencies = [ | ... | @@ -660,6 +819,7 @@ dependencies = [ |
| 660 | "base64", | 819 | "base64", |
| 661 | "bumpalo", | 820 | "bumpalo", |
| 662 | "cbc", | 821 | "cbc", |
| 822 | "file-guard", | ||
| 663 | "getrandom", | 823 | "getrandom", |
| 664 | "md5", | 824 | "md5", |
| 665 | "nix", | 825 | "nix", |
| ... | @@ -735,6 +895,17 @@ version = "0.3.34" | ... | @@ -735,6 +895,17 @@ version = "0.3.34" |
| 735 | source = "registry+https://github.com/rust-lang/crates.io-index" | 895 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 736 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | 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 | [[package]] | 909 | [[package]] |
| 739 | name = "potential_utf" | 910 | name = "potential_utf" |
| 740 | version = "0.1.6" | 911 | version = "0.1.6" |
| ... | @@ -746,6 +917,12 @@ dependencies = [ | ... | @@ -746,6 +917,12 @@ dependencies = [ |
| 746 | "zerovec", | 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 | [[package]] | 926 | [[package]] |
| 750 | name = "proc-macro2" | 927 | name = "proc-macro2" |
| 751 | version = "1.0.107" | 928 | version = "1.0.107" |
| ... | @@ -770,6 +947,12 @@ version = "6.0.0" | ... | @@ -770,6 +947,12 @@ version = "6.0.0" |
| 770 | source = "registry+https://github.com/rust-lang/crates.io-index" | 947 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 771 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | 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 | [[package]] | 956 | [[package]] |
| 774 | name = "read-fonts" | 957 | name = "read-fonts" |
| 775 | version = "0.41.0" | 958 | version = "0.41.0" |
| ... | @@ -905,6 +1088,12 @@ version = "2.0.1" | ... | @@ -905,6 +1088,12 @@ version = "2.0.1" |
| 905 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 906 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | 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 | [[package]] | 1097 | [[package]] |
| 909 | name = "skrifa" | 1098 | name = "skrifa" |
| 910 | version = "0.44.0" | 1099 | version = "0.44.0" |
| ... | @@ -1011,6 +1200,25 @@ dependencies = [ | ... | @@ -1011,6 +1200,25 @@ dependencies = [ |
| 1011 | "syn 3.0.5", | 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 | [[package]] | 1222 | [[package]] |
| 1015 | name = "tinystr" | 1223 | name = "tinystr" |
| 1016 | version = "0.8.4" | 1224 | version = "0.8.4" |
| ... | @@ -1034,6 +1242,16 @@ version = "1.0.24" | ... | @@ -1034,6 +1242,16 @@ version = "1.0.24" |
| 1034 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1035 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | 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 | [[package]] | 1255 | [[package]] |
| 1038 | name = "utf16_iter" | 1256 | name = "utf16_iter" |
| 1039 | version = "1.0.5" | 1257 | version = "1.0.5" |
| ... | @@ -1097,6 +1315,28 @@ dependencies = [ | ... | @@ -1097,6 +1315,28 @@ dependencies = [ |
| 1097 | "unicode-ident", | 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 | [[package]] | 1340 | [[package]] |
| 1101 | name = "windows" | 1341 | name = "windows" |
| 1102 | version = "0.62.2" | 1342 | version = "0.62.2" |
fuzz/Cargo.toml+1-1| ... | @@ -9,7 +9,7 @@ cargo-fuzz = true | ... | @@ -9,7 +9,7 @@ cargo-fuzz = true |
| 9 | 9 | ||
| 10 | [dependencies] | 10 | [dependencies] |
| 11 | libfuzzer-sys = "0.4" | 11 | libfuzzer-sys = "0.4" |
| 12 | onestore = { path = "../crates/onestore", features = ["protected"] } | 12 | onestore = { path = "../crates/onestore" } |
| 13 | notebook = { path = "../crates/notebook" } | 13 | notebook = { path = "../crates/notebook" } |
| 14 | canvas = { path = "../crates/canvas" } | 14 | canvas = { path = "../crates/canvas" } |
| 15 | draw = { path = "../crates/draw", default-features = false } | 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,37 +209,25 @@ fuzz_target!(|input: &[u8]| { |
| 209 | .into_iter() | 209 | .into_iter() |
| 210 | .enumerate() | 210 | .enumerate() |
| 211 | { | 211 | { |
| 212 | objects.push(onestore::page::PageObject::Image(onestore::page::Image { | 212 | let mut image = |
| 213 | id: onestore::ExGuid { | 213 | canvas::editor::picture(b"deferred decoding".to_vec(), [72.0, 40.0]).unwrap(); |
| 214 | n: index.try_into().unwrap(), | 214 | image.id = onestore::ExGuid { |
| 215 | ..Default::default() | 215 | n: index.try_into().unwrap(), |
| 216 | }, | 216 | ..Default::default() |
| 217 | layout: onestore::document::Layout { | 217 | }; |
| 218 | x: Some(x), | 218 | [image.layout.x, image.layout.y] = [Some(x), Some(position[1] + y)]; |
| 219 | y: Some(position[1] + y), | 219 | image.background = background; |
| 220 | max_width: Some(72.0), | 220 | objects.push(onestore::page::PageObject::Image(image)); |
| 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 | })); | ||
| 229 | } | 221 | } |
| 230 | CanvasEditor::from_page( | 222 | // An empty editor's page is a page with every property at its default. |
| 231 | onestore::page::Page { | 223 | let blank = TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]); |
| 232 | title: String::new(), | 224 | let mut page = CanvasEditor::new(engine, blank.unwrap(), 468.0) |
| 233 | identity: None, | 225 | .unwrap() |
| 234 | created: date.map(|date| date.timestamp()), | 226 | .page() |
| 235 | margin_origin: [0.0; 2], | 227 | .unwrap(); |
| 236 | color: None, | 228 | page.created = date.map(|date| date.timestamp()); |
| 237 | definitions: BTreeMap::new(), | 229 | page.objects = objects; |
| 238 | objects, | 230 | CanvasEditor::from_page(page, engine).unwrap() |
| 239 | }, | ||
| 240 | engine, | ||
| 241 | ) | ||
| 242 | .unwrap() | ||
| 243 | } else { | 231 | } else { |
| 244 | CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), date).unwrap() | 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,6 +1,9 @@ |
| 1 | #![no_main] | 1 | #![no_main] |
| 2 | use libfuzzer_sys::fuzz_target; | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | 3 | ||
| 4 | #[path = "../../crates/onestore/tests/support/ops.rs"] | ||
| 5 | mod ops; | ||
| 6 | |||
| 4 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] | 7 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] |
| 5 | mod model_ops; | 8 | mod model_ops; |
| 6 | #[path = "../../crates/notebook/tests/support/server.rs"] | 9 | #[path = "../../crates/notebook/tests/support/server.rs"] |
fuzz/fuzz_targets/offline_page.rs+3| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | #![no_main] | 1 | #![no_main] |
| 2 | use libfuzzer_sys::fuzz_target; | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | 3 | ||
| 4 | #[path = "../../crates/onestore/tests/support/ops.rs"] | ||
| 5 | mod ops; | ||
| 6 | |||
| 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] | 7 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
| 5 | mod disk; | 8 | mod disk; |
| 6 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] | 9 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] |
fuzz/fuzz_targets/protected.rs+15-5| ... | @@ -1,12 +1,14 @@ | ... | @@ -1,12 +1,14 @@ |
| 1 | #![no_main] | 1 | #![no_main] |
| 2 | use libfuzzer_sys::fuzz_target; | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | use onestore::{ | 3 | use onestore::{ |
| 4 | Reference, RevisionIndex, Store, | 4 | Arena, Reference, RevisionIndex, Section, Store, |
| 5 | protected::{Limits, UnlockedSection}, | 5 | protected::{Key, Limits, UnlockedSection}, |
| 6 | }; | 6 | }; |
| 7 | use std::{ops::Range, sync::LazyLock}; | 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 | (&include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one")[..], include_str!("../../corpus/native-encrypted/manifest.json")), | 13 | (&include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one")[..], include_str!("../../corpus/native-encrypted/manifest.json")), |
| 12 | (&include_bytes!("../../corpus/native-protected-boundaries/notebook/synthetic.one")[..], include_str!("../../corpus/native-protected-boundaries/manifest.json")), | 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,7 +23,9 @@ static SOURCES: LazyLock<Vec<(Vec<u8>, String, Vec<Range<usize>>)>> = LazyLock:: |
| 21 | }).collect(); | 23 | }).collect(); |
| 22 | ranges.sort_by_key(|range| (range.start, range.end)); | 24 | ranges.sort_by_key(|range| (range.start, range.end)); |
| 23 | ranges.dedup(); | 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 | }).collect() | 29 | }).collect() |
| 26 | }); | 30 | }); |
| 27 | 31 | ||
| ... | @@ -29,7 +33,7 @@ fuzz_target!(|data: &[u8]| { | ... | @@ -29,7 +33,7 @@ fuzz_target!(|data: &[u8]| { |
| 29 | if data.len() < 8 { | 33 | if data.len() < 8 { |
| 30 | return; | 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 | let mut bytes = source.clone(); | 37 | let mut bytes = source.clone(); |
| 34 | let mut password = password.clone(); | 38 | let mut password = password.clone(); |
| 35 | let mode = data[1] % 3; | 39 | let mode = data[1] % 3; |
| ... | @@ -46,6 +50,12 @@ fuzz_target!(|data: &[u8]| { | ... | @@ -46,6 +50,12 @@ fuzz_target!(|data: &[u8]| { |
| 46 | bytes[range.start + offset..range.start + offset + count] | 50 | bytes[range.start + offset..range.start + offset + count] |
| 47 | .copy_from_slice(&data[8..8 + count]); | 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 | let Ok(store) = Store::parse(&bytes) else { | 59 | let Ok(store) = Store::parse(&bytes) else { |
| 50 | return; | 60 | return; |
| 51 | }; | 61 | }; |
fuzz/fuzz_targets/protected_write.rs+35-38| ... | @@ -1,39 +1,33 @@ | ... | @@ -1,39 +1,33 @@ |
| 1 | #![no_main] | 1 | #![no_main] |
| 2 | //! Chains text edits on a protected section: every written image unlocks with the same | 2 | //! Chains text edits on a protected section kept open under its key, now and then writing |
| 3 | //! password, reads back as the model predicts and stores none of the new text in clear. | 3 | //! it anew under another password: every sealed image opens with the key, reads back as the |
| 4 | //! Text typed at the end of a text whose empty final run keeps an insertion style takes | 4 | //! model predicts and stores none of the new text in clear. Text typed at the end of a text |
| 5 | //! that style, which the page model cannot show, so only its characters are predicted. | 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 | use libfuzzer_sys::fuzz_target; | 7 | use libfuzzer_sys::fuzz_target; |
| 7 | use onestore::{ | 8 | use onestore::{ |
| 8 | ExGuid, RevisionIndex, Store, | 9 | Arena, ExGuid, Section, |
| 9 | document::Kind, | ||
| 10 | op::{Edit, Op, PageOp, predict}, | 10 | op::{Edit, Op, PageOp, predict}, |
| 11 | page::{Page, PageObject}, | 11 | page::{Page, PageObject}, |
| 12 | protected::{Limits, UnlockedSection}, | 12 | protected::{Key, rekey}, |
| 13 | }; | 13 | }; |
| 14 | use std::sync::LazyLock; | ||
| 14 | 15 | ||
| 15 | const SOURCE: &[u8] = | 16 | const SOURCE: &[u8] = |
| 16 | include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one"); | 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. | 25 | /// The first page. |
| 20 | fn page(bytes: &[u8]) -> (ExGuid, Page, Vec<ExGuid>) { | 26 | fn page(bytes: &[u8], key: &Key) -> (ExGuid, Page) { |
| 21 | let store = Store::parse(bytes).unwrap(); | 27 | let arena = Arena::default(); |
| 22 | let index = RevisionIndex::parse(&store).unwrap(); | 28 | let mut section = Section::unlock(&arena, bytes.to_vec(), key).unwrap(); |
| 23 | let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); | 29 | let (space, ..) = section.pages().unwrap()[0]; |
| 24 | let document = unlocked.document().unwrap(); | 30 | (space, section.page(space).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) | ||
| 37 | } | 31 | } |
| 38 | 32 | ||
| 39 | fn texts(page: &Page) -> Vec<String> { | 33 | fn texts(page: &Page) -> Vec<String> { |
| ... | @@ -50,11 +44,20 @@ fn texts(page: &Page) -> Vec<String> { | ... | @@ -50,11 +44,20 @@ fn texts(page: &Page) -> Vec<String> { |
| 50 | 44 | ||
| 51 | fuzz_target!(|data: &[u8]| { | 45 | fuzz_target!(|data: &[u8]| { |
| 52 | let mut bytes = SOURCE.to_vec(); | 46 | let mut bytes = SOURCE.to_vec(); |
| 47 | let mut key = &KEYS[0]; | ||
| 53 | for (at, step) in (0..).zip(data.chunks(12).take(4)) { | 48 | for (at, step) in (0..).zip(data.chunks(12).take(4)) { |
| 54 | if step.len() < 4 { | 49 | if step.len() < 4 { |
| 55 | return; | 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 | let candidates: Vec<_> = expected | 61 | let candidates: Vec<_> = expected |
| 59 | .objects | 62 | .objects |
| 60 | .iter() | 63 | .iter() |
| ... | @@ -74,7 +77,6 @@ fuzz_target!(|data: &[u8]| { | ... | @@ -74,7 +77,6 @@ fuzz_target!(|data: &[u8]| { |
| 74 | "\u{1f512}sealed\u{1f512}{}", | 77 | "\u{1f512}sealed\u{1f512}{}", |
| 75 | String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "") | 78 | String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "") |
| 76 | ); | 79 | ); |
| 77 | let styled = hidden.contains(&text.id) && stop == end; | ||
| 78 | let op = PageOp::Text { | 80 | let op = PageOp::Text { |
| 79 | text: text.id, | 81 | text: text.id, |
| 80 | range: start..stop, | 82 | range: start..stop, |
| ... | @@ -87,10 +89,10 @@ fuzz_target!(|data: &[u8]| { | ... | @@ -87,10 +89,10 @@ fuzz_target!(|data: &[u8]| { |
| 87 | at: 133_000_000_000_000_000 + at * 10_000_000, | 89 | at: 133_000_000_000_000_000 + at * 10_000_000, |
| 88 | ops: vec![Op::Page { space, op }], | 90 | ops: vec![Op::Page { space, op }], |
| 89 | }; | 91 | }; |
| 90 | let store = Store::parse(&bytes).unwrap(); | 92 | let arena = Arena::default(); |
| 91 | let index = RevisionIndex::parse(&store).unwrap(); | 93 | let mut section = Section::unlock(&arena, bytes.clone(), key).unwrap(); |
| 92 | let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); | 94 | section.apply("Fuzz", &edit).unwrap(); |
| 93 | let transaction = unlocked.apply("Fuzz", &edit).unwrap(); | 95 | let transaction = section.seal().unwrap().unwrap(); |
| 94 | let mut written = bytes.clone(); | 96 | let mut written = bytes.clone(); |
| 95 | transaction.apply(&mut written).unwrap(); | 97 | transaction.apply(&mut written).unwrap(); |
| 96 | let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect(); | 98 | let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect(); |
| ... | @@ -99,12 +101,7 @@ fuzz_target!(|data: &[u8]| { | ... | @@ -99,12 +101,7 @@ fuzz_target!(|data: &[u8]| { |
| 99 | .windows(clear.len()) | 101 | .windows(clear.len()) |
| 100 | .any(|w| w == clear) | 102 | .any(|w| w == clear) |
| 101 | ); | 103 | ); |
| 102 | let (_, stored, _) = page(&written); | 104 | assert_eq!(texts(&page(&written, key).1), texts(&expected)); |
| 103 | if styled { | ||
| 104 | assert_eq!(texts(&stored), texts(&expected)); | ||
| 105 | } else { | ||
| 106 | assert!(stored.objects == expected.objects); | ||
| 107 | } | ||
| 108 | bytes = written; | 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,6 +436,7 @@ dependencies = [ |
| 436 | "roxmltree", | 436 | "roxmltree", |
| 437 | "swash", | 437 | "swash", |
| 438 | "wgpu", | 438 | "wgpu", |
| 439 | "windows", | ||
| 439 | ] | 440 | ] |
| 440 | 441 | ||
| 441 | [[package]] | 442 | [[package]] |
platform/snow-leopard/probe/src/render.rs+2-2| ... | @@ -11,12 +11,12 @@ fn main() { | ... | @@ -11,12 +11,12 @@ fn main() { |
| 11 | fn run() { | 11 | fn run() { |
| 12 | let _context = context::current(); | 12 | let _context = context::current(); |
| 13 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); | 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 | let checker = scene::checker(); | 15 | let checker = scene::checker(); |
| 16 | let primitives = scene::primitives(&checker); | 16 | let primitives = scene::primitives(&checker); |
| 17 | let started = std::time::Instant::now(); | 17 | let started = std::time::Instant::now(); |
| 18 | renderer.draw(&target, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]).unwrap(); | 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 | println!("drew in {:?}", started.elapsed()); | 20 | println!("drew in {:?}", started.elapsed()); |
| 21 | let file = std::fs::File::create("render-probe.png").unwrap(); | 21 | let file = std::fs::File::create("render-probe.png").unwrap(); |
| 22 | let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]); | 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,13 +8,13 @@ fn main() { |
| 8 | objc2::rc::autoreleasepool(|_| { | 8 | objc2::rc::autoreleasepool(|_| { |
| 9 | let _context = context::current(); | 9 | let _context = context::current(); |
| 10 | let mut renderer = draw::Renderer::opengl().unwrap_or_else(|error| panic!("{error}")); | 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 | let ui = scene::interface(1.0); | 12 | let ui = scene::interface(1.0); |
| 13 | let layers = ui.layers(); | 13 | let layers = ui.layers(); |
| 14 | renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap(); | 14 | renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap(); |
| 15 | let file = std::fs::File::create("text-probe.png").unwrap(); | 15 | let file = std::fs::File::create("text-probe.png").unwrap(); |
| 16 | let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]); | 16 | let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]); |
| 17 | encoder.set_color(png::ColorType::Rgba); | 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,7 +20,7 @@ development builds, which never update themselves. |
| 20 | ```text | 20 | ```text |
| 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} | 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} |
| 22 | 2026-09-29.r10/ | 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 | build.json.sig ed25519 signature of build.json, hex | 24 | build.json.sig ed25519 signature of build.json, hex |
| 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip | 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip |
| 26 | Snowbound-2026-09-29-r10-macos-x86_64.zip | 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,6 +38,27 @@ in the immutable `build.json`, whose version the app checks against the one it |
| 38 | was pointed to, and a forged pointer can only name another signed build, which | 38 | was pointed to, and a forged pointer can only name another signed build, which |
| 39 | the app ignores unless it is newer than itself. | 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 | ## Signing | 62 | ## Signing |
| 42 | 63 | ||
| 43 | Every `build.json` and archive is signed with the ed25519 release key in | 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,8 +111,8 @@ python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| 90 | ``` | 111 | ``` |
| 91 | 112 | ||
| 92 | The script refuses to run unless the working copy matches `main` (a dry run | 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) | 114 | only warns), gates the commit with `tools/ci.py` (see `tools/TESTING.md`), |
| 94 | and the workspace tests, builds each platform with | 115 | builds each platform with |
| 95 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), | 116 | `tools/canvas/build_macos.py` (`--snow-leopard` for 10.6), |
| 96 | `crates/snowbound/linux/package.sh` (taking only its executables) and | 117 | `crates/snowbound/linux/package.sh` (taking only its executables) and |
| 97 | `platform/windows/cargo.sh`, then checks | 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,8 +158,9 @@ signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at |
| 137 | its own version. It stages the update beside the install, so the swap is a rename: | 158 | its own version. It stages the update beside the install, so the swap is a rename: |
| 138 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the | 159 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 139 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on | 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 | 161 | Windows). The sync status icon gets a dot, and its popup says what the update |
| 141 | Build Folder and Restart to 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 | Restart to Update quits the app and starts the old executable with | 165 | Restart to Update quits the app and starts the old executable with |
| 144 | `--finish-update`, which waits for the app to exit, renames the old bundle (or | 166 | `--finish-update`, which waits for the app to exit, renames the old bundle (or |
tools/TESTING.md+43| ... | @@ -1,5 +1,47 @@ | ... | @@ -1,5 +1,47 @@ |
| 1 | # Library regression lanes | 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 | ## Public fixtures | 45 | ## Public fixtures |
| 4 | 46 | ||
| 5 | From a checkout with Rust and [uv](https://docs.astral.sh/uv/), which provides | 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,6 +112,7 @@ the run's commands, inputs, outputs and teardown evidence. |
| 70 | | Boundary | Entry point | Acceptance evidence | | 112 | | Boundary | Entry point | Acceptance evidence | |
| 71 | | --- | --- | --- | | 113 | | --- | --- | --- | |
| 72 | | Native authoring and cold reopen | `native_runner.py --help` | Independent OneNote capture; exact expected page count when known; owned clone teardown | | 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 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | | 116 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | |
| 74 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | | 117 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | |
| 75 | | SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks | | 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,6 +52,8 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a |
| 52 | 52 | ||
| 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`). | 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 | 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. | 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 | 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"}`. | 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,9 +44,10 @@ root = Path(__file__).resolve().parents[2] |
| 44 | TEAM = '9R7DPNW28H' | 44 | TEAM = '9R7DPNW28H' |
| 45 | BUNDLE_ID = 'net.paperclover.snowbound' | 45 | BUNDLE_ID = 'net.paperclover.snowbound' |
| 46 | CONTAINER = 'iCloud.net.paperclover.snowbound' | 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 | ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'), | 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 | def developer_id(): | 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,21 +1,17 @@ |
| 1 | """Traversal of the exported document's referenced revisions.""" | 1 | """Traversal of the exported document's referenced revisions.""" |
| 2 | import os | 2 | import os |
| 3 | from pathlib import Path | 3 | from pathlib import Path |
| 4 | import shutil | ||
| 5 | import subprocess | 4 | import subprocess |
| 6 | 5 | ||
| 7 | 6 | ||
| 8 | def _exporter(): | 7 | def _exporter(): |
| 9 | """The document exporter built with protected sections, copied aside because a workspace | 8 | """The document exporter, built for this run.""" |
| 10 | build without the feature overwrites target/debug/examples/document.""" | ||
| 11 | if 'ONESTORE_DOCUMENT' in os.environ: | 9 | if 'ONESTORE_DOCUMENT' in os.environ: |
| 12 | return Path(os.environ['ONESTORE_DOCUMENT']) | 10 | return Path(os.environ['ONESTORE_DOCUMENT']) |
| 13 | root = Path(__file__).resolve().parent.parent | 11 | root = Path(__file__).resolve().parent.parent |
| 14 | subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--features', 'protected', | 12 | subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--example', 'document'], |
| 15 | '--example', 'document'], cwd=root, check=True) | 13 | cwd=root, check=True) |
| 16 | protected = root / 'target/debug/examples/document-protected' | 14 | return root / 'target/debug/examples/document' |
| 17 | shutil.copy2(root / 'target/debug/examples/document', protected) | ||
| 18 | return protected | ||
| 19 | 15 | ||
| 20 | 16 | ||
| 21 | EXPORTER = _exporter() | 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 +6,7 @@ import hashlib |
| 6 | import json | 6 | import json |
| 7 | import os | 7 | import os |
| 8 | from pathlib import Path | 8 | from pathlib import Path |
| 9 | import re | ||
| 9 | import shutil | 10 | import shutil |
| 10 | import subprocess | 11 | import subprocess |
| 11 | import sys | 12 | import sys |
| ... | @@ -21,16 +22,14 @@ PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'lin | ... | @@ -21,16 +22,14 @@ PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'lin |
| 21 | 'windows-x86_64', 'windows-aarch64'] | 22 | 'windows-x86_64', 'windows-aarch64'] |
| 22 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. | 23 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. |
| 23 | WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} | 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 | # Clover's Developer ID Application certificate, by its SHA-1 hash: its name is the account | 25 | # Clover's Developer ID Application certificate, by its SHA-1 hash: its name is the account |
| 30 | # holder's legal name, which nothing here prints or stores. | 26 | # holder's legal name, which nothing here prints or stores. |
| 31 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' | 27 | IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' |
| 32 | # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. | 28 | # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. |
| 33 | NOTARY = Path('~/.config/snowbound/notary.json').expanduser() | 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 | def derive(release, commits): | 35 | def derive(release, commits): |
| ... | @@ -79,9 +78,60 @@ def run(command, **kwargs): | ... | @@ -79,9 +78,60 @@ def run(command, **kwargs): |
| 79 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) | 78 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) |
| 80 | 79 | ||
| 81 | 80 | ||
| 82 | def commit_times(revset): | 81 | def history(): |
| 83 | template = 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\n"' | 82 | """Every commit on `main` by id: its parents' ids, when it was made, and its message.""" |
| 84 | return [datetime.fromisoformat(line) for line in jj('log', '--no-graph', '-r', revset, '-T', template).split()] | 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 | def sign(files): | 137 | def sign(files): |
| ... | @@ -164,7 +214,8 @@ def main(): | ... | @@ -164,7 +214,8 @@ def main(): |
| 164 | 214 | ||
| 165 | commit = jj('log', '--no-graph', '-r', 'main', '-T', 'commit_id').strip() | 215 | commit = jj('log', '--no-graph', '-r', 'main', '-T', 'commit_id').strip() |
| 166 | clean(args.dry_run, 'to release it') | 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 | print(f'Snowbound build {version[0]} revision {version[1]}, commit {commit}', flush=True) | 219 | print(f'Snowbound build {version[0]} revision {version[1]}, commit {commit}', flush=True) |
| 169 | 220 | ||
| 170 | published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED | 221 | published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED |
| ... | @@ -177,8 +228,7 @@ def main(): | ... | @@ -177,8 +228,7 @@ def main(): |
| 177 | sys.exit(f'{target} holds commit {build["commit"]}, not {commit}.') | 228 | sys.exit(f'{target} holds commit {build["commit"]}, not {commit}.') |
| 178 | print(f'{target} is already published.') | 229 | print(f'{target} is already published.') |
| 179 | else: | 230 | else: |
| 180 | for check in CHECKS: | 231 | run([sys.executable, ROOT / 'tools/ci.py', '--rev', commit]) |
| 181 | run(check) | ||
| 182 | clean(args.dry_run, 'after the checks') | 232 | clean(args.dry_run, 'after the checks') |
| 183 | stage = ROOT / 'target/release-stage' / name(version) | 233 | stage = ROOT / 'target/release-stage' / name(version) |
| 184 | shutil.rmtree(stage, ignore_errors=True) | 234 | shutil.rmtree(stage, ignore_errors=True) |
| ... | @@ -208,6 +258,7 @@ def main(): | ... | @@ -208,6 +258,7 @@ def main(): |
| 208 | 'version': name(version), | 258 | 'version': name(version), |
| 209 | 'commit': commit, | 259 | 'commit': commit, |
| 210 | 'published': datetime.now(ZONE).isoformat(timespec='seconds'), | 260 | 'published': datetime.now(ZONE).isoformat(timespec='seconds'), |
| 261 | 'changes': changes(commits, commit), | ||
| 211 | 'archives': {platform: { | 262 | 'archives': {platform: { |
| 212 | 'file': file.name, | 263 | 'file': file.name, |
| 213 | 'size': file.stat().st_size, | 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,6 +22,28 @@ class ReleaseTest(unittest.TestCase): |
| 22 | self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10)) | 22 | self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10)) |
| 23 | self.assertEqual(release['folder'](('2026-09-29', 10)), '2026-09-29.r10') | 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 | def test_latest_only_moves_forward(self): | 47 | def test_latest_only_moves_forward(self): |
| 26 | latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'} | 48 | latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'} |
| 27 | moved = release['newest'](latest, {'macos-aarch64': {}, 'linux-x86_64': {}, 'macos-10.6': {}}, | 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,16 +376,13 @@ def compare(notebook, native, versions=None, password_file=None): |
| 376 | source = space['nodes'][source_page['children'][z]] | 376 | source = space['nodes'][source_page['children'][z]] |
| 377 | if source['kind']['type'] == 'Ink': | 377 | if source['kind']['type'] == 'Ink': |
| 378 | if child.tag == '{%s}InkDrawing' % ns['one']: | 378 | if child.tag == '{%s}InkDrawing' % ns['one']: |
| 379 | # Stroke coordinates are absolute; left-to-right pages report them | 379 | # A drawing lies in the page's frame as outlines do: its strokes plus the |
| 380 | # relative to the canonical margin origin, right-to-left pages as stored. | 380 | # offset a move gives it, reported from the canonical margin origin. |
| 381 | extent = ink_extent(space, source) | 381 | extent = ink_extent(space, source) |
| 382 | # A moved drawing keeps its strokes and takes an offset. | 382 | for index, axis in enumerate(('x', 'y')): |
| 383 | extent[0] += source['layout']['x'] or 0.0 | 383 | origin = source_page['kind']['margin_origin_' + axis] |
| 384 | extent[1] += source['layout']['y'] or 0.0 | 384 | canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4 |
| 385 | if not source_page['kind']['rtl']: | 385 | extent[index] += (source['layout'][axis] or 0.0) + (canonical_origin - origin if origin is not None else 0) |
| 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 | ||
| 389 | size = child.find('one:Size', ns) | 386 | size = child.find('one:Size', ns) |
| 390 | # Native sizes are one HIMETRIC unit larger than the point extent. | 387 | # Native sizes are one HIMETRIC unit larger than the point extent. |
| 391 | reported = [float(position.attrib['x']), float(position.attrib['y']), float(size.get('width')) - 72 / 2540, float(size.get('height')) - 72 / 2540] | 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,11 +397,11 @@ def compare(notebook, native, versions=None, password_file=None): |
| 400 | origin = source_page['kind']['margin_origin_' + axis] | 397 | origin = source_page['kind']['margin_origin_' + axis] |
| 401 | canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4 | 398 | canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4 |
| 402 | projected = stored_position + (canonical_origin - origin if origin is not None else 0) | 399 | projected = stored_position + (canonical_origin - origin if origin is not None else 0) |
| 403 | if axis == 'x' and source_page['kind']['rtl']: | 400 | if axis == 'x' and source_page['kind']['rtl'] and source['kind']['type'] == 'Outline': |
| 404 | assert source['kind']['type'] == 'Outline' and source['layout']['max_width'] is not None, 'RTL positioning requires an outline width' | ||
| 405 | assert not any(p['id'] == 0x14001c84 for group in source['extra'] for p in group), 'Explicit RTL outline alignment requires a native control' | 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. | 402 | # An outline laid out wider than its stored width keeps its right edge. |
| 407 | projected += source['layout']['max_width'] - float(child.find('one:Size', ns).get('width')) | 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 | if abs(projected - native_position) > 0.002: | 405 | if abs(projected - native_position) > 0.002: |
| 409 | geometry.append({'file': str(relative), 'page': ordinal, 'z': z, 'axis': axis, | 406 | geometry.append({'file': str(relative), 'page': ordinal, 'z': z, 'axis': axis, |
| 410 | 'stored': stored_position, 'origin': origin, 'projected': projected, 'native': native_position}) | 407 | 'stored': stored_position, 'origin': origin, 'projected': projected, 'native': native_position}) |