authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 21:03:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 12:04:12-07:00
logf68c9103fa67fd96322bb45c6ae66086d53bc18d
treea2a1a47eb03e541c67f1ae798b82d3e6e13800c0
parent932ef789ec44c35ed71fda1ec07adcd7365eb416
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: password-protected sections, notebook recycle bin, unread changes, and Direct3D 11 on Windows 7

- 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.5

441 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]
565565
566[[package]]
567name = "byteorder"
568version = "1.5.0"
569source = "registry+https://github.com/rust-lang/crates.io-index"
570checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
571
566[[package]]572[[package]]
567name = "byteorder-lite"573name = "byteorder-lite"
568version = "0.1.0"574version = "0.1.0"
...@@ -575,6 +581,18 @@ version = "1.12.1"...@@ -575,6 +581,18 @@ version = "1.12.1"
575source = "registry+https://github.com/rust-lang/crates.io-index"581source = "registry+https://github.com/rust-lang/crates.io-index"
576checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"582checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
577583
584[[package]]
585name = "cab"
586version = "0.6.0"
587source = "registry+https://github.com/rust-lang/crates.io-index"
588checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
589dependencies = [
590 "byteorder",
591 "flate2",
592 "lzxd",
593 "time",
594]
595
578[[package]]596[[package]]
579name = "calloop"597name = "calloop"
580version = "0.13.0"598version = "0.13.0"
...@@ -900,6 +918,12 @@ dependencies = [...@@ -900,6 +918,12 @@ dependencies = [
900 "hybrid-array",918 "hybrid-array",
901]919]
902920
921[[package]]
922name = "deranged"
923version = "0.5.8"
924source = "registry+https://github.com/rust-lang/crates.io-index"
925checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
926
903[[package]]927[[package]]
904name = "digest"928name = "digest"
905version = "0.11.3"929version = "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]
9911016
992[[package]]1017[[package]]
...@@ -1849,6 +1874,12 @@ dependencies = [...@@ -1849,6 +1874,12 @@ dependencies = [
1849 "twox-hash",1874 "twox-hash",
1850]1875]
18511876
1877[[package]]
1878name = "lzxd"
1879version = "0.2.7"
1880source = "registry+https://github.com/rust-lang/crates.io-index"
1881checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168"
1882
1852[[package]]1883[[package]]
1853name = "md-5"1884name = "md-5"
1854version = "0.11.0"1885version = "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]
19501982
1951[[package]]1983[[package]]
...@@ -2042,6 +2074,11 @@ dependencies = [...@@ -2042,6 +2074,11 @@ dependencies = [
2042name = "notebook"2074name = "notebook"
2043version = "0.1.0"2075version = "0.1.0"
2044dependencies = [2076dependencies = [
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]
20692106
2107[[package]]
2108name = "num-conv"
2109version = "0.2.2"
2110source = "registry+https://github.com/rust-lang/crates.io-index"
2111checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2112
2070[[package]]2113[[package]]
2071name = "num-traits"2114name = "num-traits"
2072version = "0.2.19"2115version = "0.2.19"
...@@ -2747,6 +2790,12 @@ dependencies = [...@@ -2747,6 +2790,12 @@ dependencies = [
2747 "zerovec",2790 "zerovec",
2748]2791]
27492792
2793[[package]]
2794name = "powerfmt"
2795version = "0.2.0"
2796source = "registry+https://github.com/rust-lang/crates.io-index"
2797checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2798
2750[[package]]2799[[package]]
2751name = "presser"2800name = "presser"
2752version = "0.3.1"2801version = "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]
33963446
3397[[package]]3447[[package]]
...@@ -3591,6 +3641,25 @@ dependencies = [...@@ -3591,6 +3641,25 @@ dependencies = [
3591 "zune-jpeg",3641 "zune-jpeg",
3592]3642]
35933643
3644[[package]]
3645name = "time"
3646version = "0.3.55"
3647source = "registry+https://github.com/rust-lang/crates.io-index"
3648checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3649dependencies = [
3650 "deranged",
3651 "num-conv",
3652 "powerfmt",
3653 "serde_core",
3654 "time-core",
3655]
3656
3657[[package]]
3658name = "time-core"
3659version = "0.1.9"
3660source = "registry+https://github.com/rust-lang/crates.io-index"
3661checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3662
3594[[package]]3663[[package]]
3595name = "tiny-skia"3664name = "tiny-skia"
3596version = "0.11.4"3665version = "0.11.4"
apps/ios/Snowbound.xcodeproj/project.pbxproj-4
...@@ -7,14 +7,12 @@...@@ -7,14 +7,12 @@
7 objects = {7 objects = {
88
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 */
1413
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 = false152 private var showingPage = false
153 /// A first launch opened a page by itself.
154 private var landed = false
153 private var background = UIBackgroundTaskIdentifier.invalid155 private var background = UIBackgroundTaskIdentifier.invalid
154156
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 return206 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)? = nil223 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] in227 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 in233 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 return237 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.all315 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] in317 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.zero81 private var origin = CGPoint.zero
82 private var syncing = false82 private var syncing = false
83 private var scrolled = false83 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 = 090 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 = margin237 let skip = margin
233 resting = openingZoom()238 resting = openingZoom(skipping: skip)
234 zoomScale = resting239 zoomScale = resting
235 left = skip * resting240 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 = true306 dirty = true
302 }307 }
303308
304 /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's309 /// 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 }
311318
312 /// The empty page left of its first outline, which a phone skips on opening, keeping319 /// 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 }
331338
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 }
341437
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 = false441 tapZooming = false
345 } else {442 } else {
346 resting = scale443 // 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 }
350449
...@@ -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 }
696795
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 }
716818
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 }
737839
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 = true847 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 }
878984
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)) } }
8811018
...@@ -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 = false1073 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 found1075 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 = false1102 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 found1104 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 = false1110 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 }
9881112
989 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most1113 /// 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? in80 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 guard87 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
133133
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 }
188195
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 = shown205 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.uiColor256 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 = .secondaryLabel261 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 in388 }) { [weak self] problem in
372 if let problem {389 if let problem {
373 self?.refuse(title, problem)390 self?.alert(title, problem)
374 return391 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 return408 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 = .whileEditing423 field.clearButtonMode = .whileEditing
407 NotificationCenter.default.addObserver(424 NotificationCenter.default.addObserver(
408 forName: UITextField.textDidChangeNotification, object: field, queue: .main425 forName: UITextField.textDidChangeNotification, object: field, queue: .main
409 ) { _ in426 ) { _ 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 }
419433
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().lastPathComponent446 let base = folder.deletingPathExtension().lastPathComponent
427 let ext = folder.pathExtension447 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 nil457 return nil
438 }) { [weak self] problem in458 }) { [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] _ in496 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 == true518 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 }
537559
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 = .words620 field.autocapitalizationType = .words
573 NotificationCenter.default.addObserver(621 NotificationCenter.default.addObserver(
574 forName: UITextField.textDidChangeNotification, object: field, queue: .main622 forName: UITextField.textDidChangeNotification, object: field, queue: .main
575 ) { _ in623 ) { _ 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 : documentsDirectory632 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? in638 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 in643 }) { [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 in647 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 }
610650
...@@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
621 return nil661 return nil
622 }) { [weak self] problem in662 }) { [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 in666 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 }
634669
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)?
723752
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 }
744797
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 = nil804 section = nil
752 items = []805 items = []
753 title = tab.name806 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 }
770827
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 = problem831 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 = section836 self.section = section
780 title = section.tab.name837 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 }
817878
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 }
821882
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: IndexPath921 _ 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 in926 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 {
3030
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 }
3539
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: String130 let group: String
127 let color: [UInt8]131 let color: [UInt8]
128 let readable: Bool132 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: Bool136 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?
133139
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
143extension 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}
138151
...@@ -205,13 +218,8 @@ final class Notebook {...@@ -205,13 +218,8 @@ final class Notebook {
205 }218 }
206 let accessed = url.startAccessingSecurityScopedResource() ? url : nil219 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 }
264268
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}
325344
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.
347private 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 elsewhere357/// 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.
328private func onThisDevice(_ url: URL) -> Bool {359private 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 }
416447
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 = false471 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 @discardableResult482 @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 }
473487
474 /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while488 /// 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 @discardableResult490 @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 in505 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 notebook510 return notebook
489 }511 }
...@@ -650,13 +672,13 @@ final class Section {...@@ -650,13 +672,13 @@ final class Section {
650 return id672 return id
651 }673 }
652674
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 in678 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 }
662684
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: Bool21 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 }
100108
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 return136 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: editable174 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] _ in200 [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 }
197207
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 a223 /// 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.pageText296 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 @@
1import 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`).
6enum 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
11extension 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
29extension 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
95extension 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.
103final 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 the14 /// `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` deletes15 /// 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 to17 /// 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 no18 /// 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 }
112112
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 }
120114
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. Tags84 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's85 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`).
8792
88These rules were established against OneNote's own output: its XML export93These rules were established against OneNote's own output: its XML export
89gives outline sizes, its PDF export gives exact line breaks, and screenshots94gives 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.
182182
183## Protected sections183## Protected sections
184184
185Password-protected sections keep their encrypted structure. With the185A password-protected section keeps its structure in the clear and encrypts every
186`protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied186object and payload under one AES-128 key, which Office's Agile password encryption
187password. Edits are sealed back under the section's key, with a fresh IV for187wraps (SHA-1, 100,000 rounds). `protected::Key` opens it with the password, and
188each object. Plaintext never reaches the replica's cache. A protected section is188`Section::unlock` keeps the section open under it: objects decode into the arena as it
189edited directly in its file and never queued.189opens, and each seal encrypts what it appends with a fresh IV and names the key in every
190revision. Edits are ops like any other's; only the bytes differ.
191
192Setting, changing or removing a password is one of the few whole-image writes, as it is
193in OneNote 2010, which writes the section anew under a new file identity, new object
194space identities and new payload identities, and leaves its TOC to follow. Snowbound does
195the same (`protected::rekey`): keeping any identity would let a cache, OneNote's or a
196replica, mistake the old file's revisions or payloads for the new file's. The evidence,
197with the crypto, is in `corpus/protected-sections`.
190198
191## Looking inside199## Looking inside
192200
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.
10shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios)10shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios)
11glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI)11glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI)
12page canvas ─────────────────────────────────────────────────────────────────────►12page canvas ─────────────────────────────────────────────────────────────────────►
13paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal)13paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12; D3D11 on 7) draw (Metal)
14data notebook::session + embedded SMB client ────────────────────────────────────►14data notebook::session + embedded SMB client ────────────────────────────────────►
15format onestore ───────────────────────────────────────────────────────────────────►15format 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 8103 imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8
104 and later that dependencies still name are answered by104 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), otherwise107 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) lies117 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 8121 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 the122 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. Over123 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 white124 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 the125 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 classic126 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 on127 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 SMB135- 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 a136 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 denies137 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.
5656
57The working state is never stored. It is the base with each sealed batch57The working state is never stored. It is the base with each sealed batch
58replayed and the open batch applied, rebuilt on open. The database runs in WAL58replayed and the open batch applied, rebuilt on open.
59
60A password-protected section's replica holds nothing in the clear. Its base and sealed
61transactions are the file's own ciphertext, and its edits and payloads are sealed with
62AES-256-GCM under a key derived from the section's, so the queue opens, offline or after a
63relaunch, only once the section is unlocked. A locked section is never opened in the
64background; its edits wait until it is unlocked again. A replica of a section another device
65then protects is deleted the next time the notebook is read, unless edits wait in it; those
66wait for the key, then come back as copies of the pages they changed. The database runs in WAL
59mode with full synchronous commits, and every setting is read back to check it67mode with full synchronous commits, and every setting is read back to check it
60took.68took.
6169
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
5and `read/page-000.xml` retain the native observations. The saved store uses5and `read/page-000.xml` retain the native observations. The saved store uses
6visual left-to-right column order [Right cell, Left cell], with widths [144, 96].6visual left-to-right column order [Right cell, Left cell], with widths [144, 96].
7COM exports cells in page direction [Left cell, Right cell], with widths [96, 144].7COM exports cells in page direction [Left cell, Right cell], with widths [96, 144].
8The native PDF independently confirms the physical cell order. Default outline alignment anchors the right edge:8The native PDF independently confirms the physical cell order. Native x is store x -
9store x + LayoutMaxWidth - PageMarginOriginX - 36 equals native x + native width.9PageMarginOriginX - 36; the native table outline measures wider than its stored suggested
10The native table outline measures wider than its stored suggested maximum width.10maximum width and keeps its right edge, store x + LayoutMaxWidth - PageMarginOriginX - 36
11equalling native x + native width (`corpus/rtl-page` has narrower outlines keep their left).
11Vertical coordinates retain the 14.4-point canonical origin.12Vertical coordinates retain the 14.4-point canonical origin.
1213
13The public fixture includes images, a file and two opaque ink objects. All14The 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
3OneNote 2010's Save As offers a page and a section as a OneNote 2010 section (`.one`), 2007
4section, PDF, XPS or single-file web page, and a notebook as a OneNote Package (`.onepkg`), PDF
5or 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`
20exports with `NOTEBOOK_PACKAGE_EXPORT`: Snowbound's MSZIP package of a notebook with a section
21group, and the notebook Snowbound unpacked from it. `onenote-unpacked` is OneNote 2010 unpacking
22Snowbound's package through its Unpack Notebook dialog; `cold` is a fresh OneNote 2010's read of
23Snowbound's unpacked notebook, every file left as written. `save-as/candidate` holds Save As's
24copy of a section and section of a page as `save_as_writes_copies_outside_the_notebook` exports
25them with `NOTEBOOK_SAVE_AS_EXPORT`; `save-as/cold` is OneNote 2010 opening their folder, which
26adopts them (new identities, placed) and reads each page. `tools/test_notebook_package.py`
27checks 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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-package/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-package/save-as/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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 @@
22
3`candidate/notebook` is `native-encrypted/encrypted-01` after3`candidate/notebook` is `native-encrypted/encrypted-01` after
4a Rust edit of the unlocked page appended text to the first paragraph and added a4a Rust edit of the unlocked page appended text to the first paragraph and added a
5paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key`5paragraph, exported by the protected writer that `Section::unlock` has since replaced
6(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a6(`corpus/protected-sections` gates the current one). `candidate/read` is OneNote 2010's COM read from a
7fresh clone with an empty cache after unlocking the section in its UI with the7fresh clone with an empty cache after unlocking the section in its UI with the
8fixture password (`../native-encrypted/manifest.json`).8fixture password (`../native-encrypted/manifest.json`).
99
corpus/protected-sections/README.md created+58
...@@ -0,0 +1,58 @@
1# Password-protected sections
2
3OneNote 2010 (14.0.4763.1000, Windows 7 lab) setting, changing and removing a section's
4password, and Snowbound doing the same to a notebook that a fresh OneNote then cold-opens.
5Passwords 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
47gave 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
53into five sections, each given a password through `Notebook::set_password`; `Edited` edited
54through its session three times, `Changed` given another password and edited, `Removed`
55unprotected, `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
57dialog, typed into `Edited` through COM and read every page (`cold/`, with the notebook
58OneNote 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 @@
1Fictitious 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
3lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi:
4embed' 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
5style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
6style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
7style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
8style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
9lang=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
10AAAASUVORK5CYII=</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 @@
1Fictitious 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
3style='font-family:Calibri' lang=en-US>Fictitious: café, </span><span
4style='font-family:SimSun' lang=en-US>東京</span><span style='font-family:Calibri'
5lang=en-US>, </span><span style='font-family:Calibri;direction:rtl;unicode-bidi:
6embed' 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
7style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
8style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
9style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
10style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
11lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial'
12lang=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
13AAAASUVORK5CYII=</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
3lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi:
4embed' 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
5style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
6style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
7style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
8style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
9lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial'
10lang=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
11AAAASUVORK5CYII=</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
3lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi:
4embed' 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
5style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
6style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
7style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
8style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
9lang=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
10AAAASUVORK5CYII=</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
3lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi:
4embed' 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
5style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
6style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
7style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
8style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
9lang=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
10AAAASUVORK5CYII=</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
3lang=en-US>Fictitious: café, 東京, </span><span style='direction:rtl;unicode-bidi:
4embed' 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
5style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
6style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
7style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
8style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
9lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Arial'
10lang=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
11AAAASUVORK5CYII=</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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/protected-sections/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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
3OneNote 2010's Notebook Recycle Bin (Share, observed in a lab clone on 2026-10-01): the bin
4opens 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
6of this Recycle Bin. Content here is deleted after 60 days."). A page's Move or Copy takes it to
7any section; a section's moves it into the notebook or a group, its file moving there. Empty
8Recycle Bin asks "Are you sure you want to empty the Recycle Bin for this notebook?", deletes
9Deleted Pages' pages in one revision and each binned section's file, and leaves the bin's TOC
10byte 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
15sections deleted into the bin. `candidate/after` is it once Snowbound restored a page (its
16identity kept), copied one out (a new identity), deleted one for good, restored a section to the
17notebook and emptied the bin. `cold/before` and `cold/after` are fresh OneNote 2010 reads of
18each: 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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recycle-bin-view/cold/after/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recycle-bin-view/cold/before/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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
3OneNote 2010 keeps a right-to-left page's positions in the same left-to-right frame
4as any page, from a margin origin that `corpus/m6/native-page-direction-03` stores at
5x 10763.25: native XML x is stored x minus the origin minus 36. It opens the page
6scrolled to the right end of its content and keeps the view's right edge when the
7window resizes. A click on blank page puts an outline's left edge on the grid
8anchored at the margin origin, as on any page; only an outline laid out wider than
9its stored width keeps its right edge. A new drawing stores page-frame strokes with
10a zero offset (Win7 lab, 2026-10-01).
11
12`candidate/` is the output of `crates/canvas/tests/rtl_page.rs` on that page through
13the page view: it opens at the drawings' right edge, a click and typing add an outline,
14a pen stroke adds a drawing, and a header drag moves the positioned outline two grid
15columns right, each published as a revision. `cold/` is a fresh OneNote 2010 read of
16it: 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
19cold-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 @@
1Fictitious 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
3style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
4style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
5style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
6style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
7lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing 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
8URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG
9IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB
10mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ
11QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY
12BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn
13NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE
14BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ
15wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW
16ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ
17GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU
18RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87
19QQrSFUA=</one:Data></one:InkDrawing><one:InkDrawing 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
20v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF
21CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg
22KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z
23mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ
24qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG
25AEAKABEgLMlfQdkfI0A=</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
26AAAASUVORK5CYII=</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
27Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRE+Zx9
28BQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXBWC/xca9f4uMc5SVKSpSIL/
29AFTB/gCn3AFgAAUURgDAAAAJBAUURgAAAAASABEgzCikRHwBF0ik6a3tvqHwOgo/QCO0AoyWs9A7
30gAMVRgBACj9AI0bCHMUqWCWg
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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/rtl-page/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/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
46name = "render_page"46name = "render_page"
47required-features = ["gpu"]47required-features = ["gpu"]
4848
49[[example]]
50name = "reading_inventory"
51required-features = ["gpu"]
52
49[[example]]53[[example]]
50name = "print_pdf"54name = "print_pdf"
51required-features = ["pdf"]55required-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
8use canvas::{
9 gpu::{Paper, Viewport, page::PageScene},
10 layout::TextEngine,
11 reading::{self, Kept, Kind, Refusal, Verdict},
12};
13use draw::Renderer;
14use onestore::{Arena, Section, page::Page};
15use 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%.
25const POINT: f32 = 96.0 / 72.0 * 1.15;
26
27fn 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
42fn 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
64fn 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
73fn 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
83fn 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
96fn 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
212struct Gpu {
213 renderer: Renderer,
214}
215
216impl 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.
352fn 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}
546546
547fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) {547/// The passwords the roots' `manifest.json` files record for their protected sections.
548fn 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
572fn 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.
22
3use crate::document::descendants;
3use onestore::{4use 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.
22pub fn changes(version: Page, older: Option<&Page>) -> Page {23pub 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 texts27 .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}
3647
37fn 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
64fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page {48fn 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.layout32 &self.layout
33 }33 }
3434
35 pub fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> {35 pub(crate) fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> {
36 let swapped = self36 let swapped = self
37 .source37 .source
38 .paragraphs38 .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)]
27pub(crate) struct DocumentEdit {27pub(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}
3535
36pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> {36pub(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 }
759759
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}
101101
102/// A new picture of encoded `bytes`, `size` points large.102/// A new picture of encoded `bytes`, `size` points large.
103pub(crate) fn picture(103pub 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 }
379377
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 }
383381
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 }
510508
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 {
587585
588 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each586 /// 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 }
972950
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 }
15181498
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 true1639 true
1664 }1640 }
...@@ -1748,7 +1724,6 @@ impl CanvasEditor {...@@ -1748,7 +1724,6 @@ impl CanvasEditor {
1748 }1724 }
1749 }1725 }
17501726
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 word1727 /// 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 }
17611736
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 }
20352010
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 }
20402015
...@@ -2052,7 +2027,7 @@ impl CanvasEditor {...@@ -2052,7 +2027,7 @@ impl CanvasEditor {
2052 }2027 }
20532028
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 }
20652040
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 }
20842059
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 }
20962070
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 the2205 /// 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 there2206 /// 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 }
24082374
...@@ -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_origin2575 self.header.margin_origin
2606 }2576 }
26072577
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 }
27612737
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 }
27952771
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 result2804 result
2811 }2805 }
28122806
...@@ -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 }
28402828
...@@ -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 }
29592928
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 }
34503419
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 }
34553424
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 {
35063475
3507 /// Replaces the selection with `text` in the formatting of the text it replaces, as a3476 /// 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 = self3485 let format = self
...@@ -3752,7 +3725,7 @@ impl CanvasEditor {...@@ -3752,7 +3725,7 @@ impl CanvasEditor {
37523725
3753 /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the3726 /// 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 {
39943967
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}
49304903
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`.
4935fn holds(content: &ParagraphContent, id: ExGuid) -> bool {4905fn 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}
49424912
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.
4943fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {4916fn 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 = new4918 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 }
187187
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 }
192192
...@@ -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 result274 result
283 }275 }
284276
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 }
294294
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}
455455
456pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {456pub(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}
469469
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::RemoveTags754 | Formatting::RemoveTags
755 | Formatting::Check755 | 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 = self837 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 }
10201011
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's1026 /// 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 the1027 /// 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 = self1184 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 = tags1185 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 }
12861279
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 }
13081302
1309 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are1303 /// 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}
13451319
crates/canvas/src/editor/ink.rs+30-48
...@@ -35,7 +35,7 @@ impl Pen {...@@ -35,7 +35,7 @@ impl Pen {
35 }35 }
36 }36 }
3737
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>) = ink135 let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink
153 .strokes136 .strokes
154 .iter()137 .iter()
...@@ -216,13 +199,13 @@ impl CanvasEditor {...@@ -216,13 +199,13 @@ impl CanvasEditor {
216199
217 /// The page's drawings, on top first, with most of their points inside `lasso`, a200 /// 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.objects203 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 = points210 let inside = points
228 .iter()211 .iter()
...@@ -236,28 +219,11 @@ impl CanvasEditor {...@@ -236,28 +219,11 @@ impl CanvasEditor {
236 }219 }
237220
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.objects237 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 {
290253
291 /// Shows drawings dragged `delta` from where they lie, without storing it, until the254 /// 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 }
296259
...@@ -427,6 +390,25 @@ impl CanvasEditor {...@@ -427,6 +390,25 @@ impl CanvasEditor {
427 }390 }
428}391}
429392
393/// Whether `stroke`, of a drawing at `offset`, passes within `reach` of segment `path`, its pen's
394/// width included.
395fn 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
430fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool {412fn 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 label3//! 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.
55
6use super::format::{leaves_mut, restyle, selected};6use super::format::restyle;
7use super::*;7use super::*;
88
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)]
11pub struct Link {11pub 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 is13 /// 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}
1919
20impl Link {20impl Link {
...@@ -193,7 +193,7 @@ impl CanvasEditor {...@@ -193,7 +193,7 @@ impl CanvasEditor {
193193
194 /// The address of the link drawn under document point `point` of outline `id`, which a194 /// 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 }
415415
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}
287287
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.
290pub(super) fn tags(289pub(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}
308307
308/// A page picture's stored position, size and description.
309pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {309pub(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;
1010
11/// A title object's own state, plus the child origins `build` replaces with page coordinates.11/// A title object's own state, plus the child origins `build` replaces with page coordinates.
12pub(crate) struct TitleArea {12pub(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}
1818
19pub(crate) struct Import {19pub(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}
2727
28pub(crate) enum Content {28pub(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 }
102101
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]` page425/// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page
436/// points.426/// points.
437pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {427pub(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
442pub(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}
445430
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];
259259
260impl CanvasEditor {260impl 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.objects264 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 or293 /// 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 the294 /// 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 {
109109
110 /// How far back from `line` Insert Space can take the page along `axis`: never over what110 /// 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 }
115115
116 /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along116 /// 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 .0120 .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 ops157 // 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 }
194194
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`, which195 /// 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 the196 /// 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`.
72fn 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}
7786
87/// A new bordered table of `rows` and `columns` new columns, in `source`'s place in the
88/// outline's tree.
89fn 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
78fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> {121fn 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 {
250impl TextOutline {293impl TextOutline {
251 /// The table column whose right border lies within `reach` of outline-local `point`, as294 /// 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 {
276impl CanvasEditor {323impl CanvasEditor {
277 /// The active outline with column `column` of `table` `width` wide, as a border drag324 /// 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 split542 split
554 .replacement543 .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 = row716 let cells = row
745 .cells717 .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 {
301301
302 /// Follows `editor` after an edit or undo brought page-level pictures the scene has not302 /// 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 shows416 /// 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 {
518513
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 {
644633
645 /// Bounds of noneditable content in page coordinates, each with whether OneNote's view634 /// 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 }
712692
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 {
912875
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 = editor895 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 {
11201055
1121impl crate::outline::OutlineLayout {1056impl 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 black1076 /// 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 {
12001135
1201 /// One paragraph of this outline, whose origin is `origin`: its text or equation, list1136 /// 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 {
12911226
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 }
13181253
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;
410pub(super) struct Background {410pub(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}
419419
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 {
430430
431impl Background {431impl 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.variants434 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 }
438438
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.size441 self.svg == other.svg && self.size == other.size
442 }442 }
443443
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 }
473473
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 per480 /// 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 raster481 /// 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}
3131
32impl Picture {32impl 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.bytes35 self.bytes == other.bytes
37 }36 }
3837
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 }
4949
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 }
5454
55 pub fn failed(&self) -> bool {55 pub(crate) fn failed(&self) -> bool {
56 self.failed56 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.
36pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"];36pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"];
3737
38pub type Result<T> = std::result::Result<T, Box<dyn Error>>;38pub(crate) type Result<T> = std::result::Result<T, Box<dyn Error>>;
3939
40#[derive(Clone, Copy, Debug, PartialEq)]40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum Cursor {41pub enum Cursor {
...@@ -112,7 +112,7 @@ pub struct Correction {...@@ -112,7 +112,7 @@ pub struct Correction {
112pub struct Response {112pub 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}
118118
...@@ -359,7 +359,7 @@ impl PageView {...@@ -359,7 +359,7 @@ impl PageView {
359 }359 }
360360
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 }
437437
438 /// OneNote 2010 opens a page scrolled fully left and up, however far down its content438 /// 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 }
443449
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 }
482488
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) => scene640 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 }
646653
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 }
786790
787 /// Moves the caret by lines until it has gone a view's height, then scrolls as far so791 /// 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.focused994 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.caret995 self.caret
1004 } else {996 } else {
1005 0.0997 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 }
11131104
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 {
17831778
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 {
17921787
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; see1797 /// 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 = self1819 let awaited = self
...@@ -1845,7 +1840,7 @@ impl PageView {...@@ -1845,7 +1840,7 @@ impl PageView {
18451840
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 icon1874 /// 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.preview1880 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}
22712256
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.
2273pub fn page_hit_test(2258pub(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 {
1010
11impl Scroll {11impl Scroll {
12 /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond12 /// 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 }
3041
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 {
135135
136/// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks136/// 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.
138pub fn tag(lcid: u32) -> Option<&'static str> {138pub(crate) fn tag(lcid: u32) -> Option<&'static str> {
139 LCIDS139 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)]
43pub struct TextBrush {43pub 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}
5151
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 at72/// 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)]
75pub struct InlineSpace {75pub(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}
8181
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 {
106impl std::error::Error for LayoutError {}106impl std::error::Error for LayoutError {}
107107
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.
109pub const DEFAULT_FONT: &str = "Arial";109pub(crate) const DEFAULT_FONT: &str = "Arial";
110pub const DEFAULT_FONT_SIZE: f32 = 11.0;110pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0;
111111
112/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font112/// 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 }
426426
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 {
531531
532impl TextLayout {532impl 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.id535 self.id
536 }536 }
537537
...@@ -592,11 +592,11 @@ impl TextLayout {...@@ -592,11 +592,11 @@ impl TextLayout {
592 last.top + last.height592 last.top + last.height
593 }593 }
594594
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 }
598598
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 = self600 let index = self
601 .lines601 .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 {
645645
646 /// Each line's part of `selection` as a mark under the text runs: its left, its right646 /// 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 selection649 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;
5pub mod layout;5pub mod layout;
6pub mod math;6pub mod math;
7pub mod outline;7pub mod outline;
8pub mod reading;
8pub mod search;9pub mod search;
9pub mod spelling;10pub mod spelling;
10pub mod template;11pub mod template;
...@@ -17,6 +18,16 @@ pub mod interaction;...@@ -17,6 +18,16 @@ pub mod interaction;
17#[cfg(feature = "pdf")]18#[cfg(feature = "pdf")]
18pub mod print;19pub mod print;
1920
21/// Where an object stored with `layout` lies; an unset coordinate is zero.
22pub(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]`.
27pub(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.
21pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {32pub 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;
11pub struct MathLayout {11pub 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}
1919
20#[derive(Clone)]20#[derive(Clone)]
21pub enum MathItem {21pub(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 {
4949
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.
51pub(crate) struct Relayout {51pub(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}
5959
60#[derive(Clone)]60#[derive(Clone)]
61pub struct ObjectLayout {61pub(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}
7171
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)]
74pub struct BlockTags {74pub(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}
8484
85impl BlockTags {85impl BlockTags {
...@@ -130,7 +130,7 @@ impl BlockTags {...@@ -130,7 +130,7 @@ impl BlockTags {
130}130}
131131
132#[derive(Clone)]132#[derive(Clone)]
133pub enum ObjectKind {133pub(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}
142142
143impl ObjectLayout {143impl 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}
248248
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}
256256
257impl CellLayout {257impl 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 }
267267
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 its297 /// 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}
301301
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 }
320320
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}
325325
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.
327pub fn checkable(shape: u16) -> bool {327pub(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..=99330 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 {
498498
499impl ParagraphTag {499impl 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;
506506
...@@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>(...@@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>(
1258impl OutlineLayout {1258impl OutlineLayout {
1259 /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each1259 /// 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 -self1262 -self
1263 .paragraphs1263 .paragraphs
1264 .iter()1264 .iter()
...@@ -1296,7 +1296,7 @@ impl OutlineLayout {...@@ -1296,7 +1296,7 @@ impl OutlineLayout {
1296 }1296 }
12971297
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 = table1301 let cell = table
1302 .cells1302 .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
11use crate::{
12 editor::{CanvasEditor, EditorError, TextOutline, page::Content},
13 layout::TextEngine,
14};
15use onestore::{
16 ExGuid,
17 page::{Outline, Page, PageObject, ParagraphContent},
18};
19use std::{collections::BTreeMap, ops::Range};
20
21/// Points between blocks in the column: OneNote's grid row.
22const GAP: f32 = 18.0;
23/// Strokes this near one another are one drawing, as handwriting is many strokes.
24const 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.
27const CANVAS_SHARE: f64 = 0.5;
28/// How far below a paragraph's top a note beside it may start and still follow it.
29const ASIDE_SLACK: f32 = 6.0;
30/// How far an aside sits in from the column's edge, and the room above and below it.
31const ASIDE_INDENT: f32 = 18.0;
32const ASIDE_GAP: f32 = 6.0;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub 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)]
45pub 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)]
58pub 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)]
70pub 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)]
83pub 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
92pub 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
100impl 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.
108pub 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.
183fn 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.
218fn 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.
248type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>);
249
250/// The page's content as laid out, overlapping pieces and nearby strokes joined.
251fn 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
345fn 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.
356fn 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.
398fn 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.
447fn 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.
463fn 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.
514fn 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.
605fn 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
627fn 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.
632fn 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, and2//! 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.
44
5use crate::document::TextPosition;5use crate::document::{TextPosition, descendants};
6use crate::editor::{CanvasEditor, Selection};6use crate::editor::{CanvasEditor, Selection};
7use icu_normalizer::DecomposingNormalizerBorrowed;7use icu_normalizer::DecomposingNormalizerBorrowed;
8use onestore::ExGuid;8use onestore::ExGuid;
...@@ -67,7 +67,7 @@ impl Query {...@@ -67,7 +67,7 @@ impl Query {
67 }67 }
6868
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, tables180/// Calls `visit` with each text paragraph of the page's outlines, in page order, tables
181/// cell by cell.181/// cell by cell.
182fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) {182fn 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's194/// 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.
208fn picture_text(page: &Page) -> Vec<&str> {196fn 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}
236211
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> {
6060
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)]
63pub struct Mark {63pub(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}
6868
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 {
352352
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 {
366366
367 /// The marks on `paragraph`, checked on this thread where not yet checked, as the367 /// 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 }
379379
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.shared382 self.shared
383 .dictionary383 .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
4use canvas::{
5 layout::TextEngine,
6 reading::{self, Kept, Kind, Refusal, Verdict},
7};
8use onestore::{
9 Arena, ExGuid, Section,
10 page::{Page, PageObject},
11};
12use std::collections::BTreeSet;
13
14const COLUMN: f32 = 230.0;
15
16fn 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
23fn 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]
35fn 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]
74fn 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]
84fn 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
9use canvas::{
10 editor::Pen,
11 gpu::page::PageScene,
12 interaction::{PageView, ink::Tool},
13 layout::TextEngine,
14};
15use onestore::{
16 Arena, ExGuid, Section, Store,
17 op::{Edit, Op},
18 page::{Page, PageObject},
19};
20use std::time::{Duration, Instant};
21
22const SOURCE: &[u8] =
23 include_bytes!("../../../corpus/m6/native-page-direction-03/notebook/synthetic.one");
24
25fn 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
34fn 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]
52fn 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]
8default = ["wgpu"]8default = ["wgpu"]
9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as10# 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.
12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
13wgpu = ["render", "dep:wgpu"]13wgpu = ["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.
15pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]15pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1616
...@@ -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 }
28swash = { version = "0.2.10", optional = true }28swash = { version = "0.2.10", optional = true }
29wgpu = { workspace = true, optional = true }29wgpu = { workspace = true, optional = true }
3030
31[target.'cfg(windows)'.dependencies]
32windows = { 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]
32pollster = "0.4"42pollster = "0.4"
33png = "0.18"43png = "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.
3struct 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
15Texture2D atlas : register(t0);
16SamplerState atlas_sampler : register(s0);
17
18Vertex 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
34float 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
51float2 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
59float 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
76float 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
95float4 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 bright432/// `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.
434pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] {434pub 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 its2//! 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.
44
5pub mod edit;5pub mod edit;
6#[cfg(feature = "render")]6#[cfg(feature = "render")]
...@@ -8,3 +8,21 @@ mod render;...@@ -8,3 +8,21 @@ mod render;
88
9#[cfg(feature = "render")]9#[cfg(feature = "render")]
10pub use render::*;10pub use render::*;
11
12/// sRGB's encoding of a linear channel.
13fn 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.
22fn 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))]
2mod d3d11;
3#[cfg(all(feature = "wgpu", windows))]
2mod dual;4mod dual;
3#[cfg(any(windows, not(feature = "wgpu")))]5#[cfg(any(windows, not(feature = "wgpu")))]
4mod gl;6mod gl;
...@@ -19,8 +21,6 @@ use gl as backend;...@@ -19,8 +21,6 @@ use gl as backend;
19use webgpu as backend;21use webgpu as backend;
2022
21pub use backend::Target;23pub use backend::Target;
22#[cfg(all(feature = "wgpu", windows))]
23pub use gl::Target as GlTarget;
24pub use icon::{Palette, picture_icon};24pub use icon::{Palette, picture_icon};
25#[cfg(feature = "pdf")]25#[cfg(feature = "pdf")]
26pub use pdf::{Sheet, pdf};26pub use pdf::{Sheet, pdf};
...@@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob;...@@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob;
34use parley::fontique::Blob;34use parley::fontique::Blob;
35use std::{35use std::{
36 collections::{HashMap, HashSet},36 collections::{HashMap, HashSet},
37 ffi::CStr,
38 mem::offset_of,
37 ops::Range,39 ops::Range,
38};40};
39use swash::{41use swash::{
...@@ -171,14 +173,25 @@ impl RasterImage {...@@ -171,14 +173,25 @@ impl RasterImage {
171 }173 }
172}174}
173175
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.
175struct Frame<'a> {178struct 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}
181184
185impl 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
182struct CachedImage {195struct 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}
194207
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
196struct 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)]
203enum Blend {211enum 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}
212222
223#[derive(Clone, Copy)]
224enum Factor {
225 Zero,
226 One,
227 SourceAlpha,
228 OneMinusSourceAlpha,
229 Destination,
230}
231
232impl 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)]
215struct Vertex {262struct Vertex {
...@@ -228,6 +275,19 @@ struct Vertex {...@@ -228,6 +275,19 @@ struct Vertex {
228 blur: f32,275 blur: f32,
229}276}
230277
278/// Each `Vertex` field's name in the shaders, float count and offset, in shader input order.
279const 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)]
232enum AtlasKey {292enum AtlasKey {
233 Text {293 Text {
...@@ -313,6 +373,18 @@ impl Motion {...@@ -313,6 +373,18 @@ impl Motion {
313 }373 }
314}374}
315375
376impl 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)]
318struct Space {390struct Space {
...@@ -324,13 +396,18 @@ struct Space {...@@ -324,13 +396,18 @@ struct Space {
324}396}
325397
326impl Space {398impl 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}
518595
596#[cfg(any(windows, not(feature = "wgpu")))]
597impl 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
519impl Renderer {622impl 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 renderer637 renderer
535 }638 }
536639
...@@ -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 }
541644
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 usize646 MAX_GLYPHS * (self.gpu.atlas_side() / ATLAS_SIZE).pow(2) as usize
555 }647 }
556648
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 }
566658
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 }
571663
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_IMAGES698 && 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 } = primitive731 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, the756 // 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 }
739821
740 /// Makes the batches from `start` a group appearing by `motion`: the vertices that822 /// 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 in823 /// 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 }
799878
...@@ -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 = run1062 let color = run
...@@ -1119,7 +1182,7 @@ impl Renderer {...@@ -1119,7 +1182,7 @@ impl Renderer {
11191182
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 width1289 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}
15861633
1587fn srgb_byte(value: f32) -> u8 {1634fn 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}
15951637
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.
1600fn text_coverage(tone: u8) -> [u8; 256] {1642fn 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 coverage1650 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}
16131655
1614fn 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.
1623const LEGIBLE: f32 = 0.4;1657const LEGIBLE: f32 = 0.4;
16241658
...@@ -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] {
16701704
1671/// Linear RGBA of an opaque sRGB colour.1705/// Linear RGBA of an opaque sRGB colour.
1672pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {1706pub 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}
16761710
...@@ -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] {
16811715
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.
1683pub fn hue(color: [f32; 4]) -> f32 {1717pub 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.921720
1687 } else {1721/// Hue in degrees, saturation and lightness of an encoded sRGB colour.
1688 1.055 * value.powf(1.0 / 2.4) - 0.0551722fn 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) / range1731 (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.01735 (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.
1741fn 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}
17051748
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];
17081751
1709/// An sRGB hue, saturation and lightness as linear RGBA.1752/// An sRGB hue, saturation and lightness as linear RGBA.
1710pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] {1753pub 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}
17191758
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.
5use super::*;
6use std::{ffi::c_void, ptr};
7use 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.
26const DRAW_VERTEX: &[u8] = include_bytes!("dxbc/draw.vertex.dxbc");
27const DRAW_FRAGMENT: &[u8] = include_bytes!("dxbc/draw.fragment.dxbc");
28const TRANSLUCENT_VERTEX: &[u8] = include_bytes!("dxbc/translucent.vertex.dxbc");
29const TRANSLUCENT_FRAGMENT: &[u8] = include_bytes!("dxbc/translucent.fragment.dxbc");
30
31const FORMAT: DXGI_FORMAT = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;
32
33/// What a successful creating call leaves in its out-parameter.
34fn 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.
41fn 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
71fn description(texture: &ID3D11Texture2D) -> D3D11_TEXTURE2D_DESC {
72 let mut description = D3D11_TEXTURE2D_DESC::default();
73 unsafe { texture.GetDesc(&mut description) };
74 description
75}
76
77fn 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.
83pub(super) struct Image(Option<ID3D11ShaderResourceView>);
84
85/// An offscreen frame, which `Renderer::present` shows in the window.
86pub struct Target {
87 texture: ID3D11Texture2D,
88 view: ID3D11RenderTargetView,
89 resource: ID3D11ShaderResourceView,
90}
91
92impl 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
111pub(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
132impl 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
528type 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`.
536fn 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.
581fn 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.11//! 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
3use super::{gl, webgpu, *};3//! renderer picks one.
4use super::{d3d11, gl, webgpu, *};
5use std::ffi::c_void;
46
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.
6pub enum Target {8pub enum Target {
7 Wgpu(wgpu::TextureView),9 Wgpu(wgpu::TextureView),
10 D3d11(d3d11::Target),
8 Gl(gl::Target),11 Gl(gl::Target),
9}12}
1013
...@@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target {...@@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target {
14 }17 }
15}18}
1619
17impl From<gl::Target> for Target {20impl 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}
2229
23pub(super) enum Image {30pub(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}
2735
...@@ -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
45impl 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}
4562
46pub(super) enum Gpu {63pub(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}
5068
51impl Renderer {69impl 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
75impl From<gl::Gpu> for Gpu {
76 fn from(gpu: gl::Gpu) -> Self {
77 Self::Gl(gpu)
54 }78 }
79}
5580
56 /// Draws with the OpenGL context current on this thread, which must stay current81impl 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}
6190
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) {92macro_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}
69101
70impl Gpu {102impl 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 }
77113
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 }
84126
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 }
91136
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 }
98152
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 }
105156
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'
4Add-Type -TypeDefinition @'
5using System;
6using System.IO;
7using System.Runtime.InteropServices;
8using System.Text;
9
10[ComImport, Guid("8BA5FB08-5195-40e2-AC58-0D989C3A0102"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
11public interface ID3DBlob {
12 [PreserveSig] IntPtr GetBufferPointer();
13 [PreserveSig] UIntPtr GetBufferSize();
14}
15
16public 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'@
43foreach ($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 @@
7use super::*;7use super::*;
8use std::{8use 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};
1312
...@@ -250,9 +249,6 @@ functions! {...@@ -250,9 +249,6 @@ functions! {
250 );249 );
251}250}
252251
253/// Blending a premultiplied picture over what lies beneath.
254const 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.
257pub(super) struct Image {253pub(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}
306302
307impl Target {303impl 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.size326 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}
380329
381impl Drop for Target {330impl 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}
398347
399/// Each attribute's name, component count and offset, bound to its index in the shader.
400const 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"))]
413impl 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
427impl Gpu {348impl 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 }
458379
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 }
520470
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 @@
1use super::{from_hsl, to_hsl};
1use swash::{2use 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);229fn 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}
235235
236fn hex(value: &str) -> [f32; 4] {236fn 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 colors365 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 / count369 / 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 }
380380
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}
393394
394fn 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
402fn 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.
411fn 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
429fn 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.
438fn bounds(data: &str) -> [[f32; 2]; 2] {396fn 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 ..plain533 ..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 }
588546
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}
108104
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 = layer257 // 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 .clip260 .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 }
298294
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 writer302 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 state310 state
311 }
312 };
313 Used::add(&mut used.states, state);
314 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
315 }315 }
316316
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 content607 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 self762 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.
3Texture2D frame : register(t0);
4
5float4 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
10float3 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
15float4 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 {
17pub(super) struct Gpu {17pub(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}
3331
34#[cfg(not(windows))]
35impl Renderer {32impl 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 }
3936
37 #[cfg(not(windows))]
40 pub fn device(&self) -> &wgpu::Device {38 pub fn device(&self) -> &wgpu::Device {
41 &self.gpu.device39 &self.gpu.device
42 }40 }
4341
42 #[cfg(not(windows))]
44 pub fn queue(&self) -> &wgpu::Queue {43 pub fn queue(&self) -> &wgpu::Queue {
45 &self.gpu.queue44 &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 }
171156
...@@ -178,11 +163,11 @@ impl Gpu {...@@ -178,11 +163,11 @@ impl Gpu {
178 }163 }
179164
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 }
183168
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 }
187172
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 }
211196
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 }
297216
298 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared217 /// 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.queue244 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}
367283
368/// An empty atlas `side` texels square, and how the pipeline samples it.284fn component([source, destination]: [Factor; 2]) -> wgpu::BlendComponent {
369fn 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
299fn 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,322fn 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.
347fn 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"
19resvg = { version = "0.48.1", default-features = false }19resvg = { version = "0.48.1", default-features = false }
20serde = { version = "1", features = ["derive"] }20serde = { version = "1", features = ["derive"] }
21serde_json = "1"21serde_json = "1"
22# Clears a password handed in to unlock a section.
23zeroize = "1.9.0"
2224
23[target.'cfg(target_os = "ios")'.dependencies]25[target.'cfg(target_os = "ios")'.dependencies]
24objc2 = "0.5.2"26objc2 = "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);
34char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);34char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
35char *sb_library_tagged(const Library *library, const Section *open, const char *path);35char *sb_library_tagged(const Library *library, const Section *open, const char *path);
36bool sb_library_unlock(const Library *library, const char *path, const char *password);
37void sb_library_lock_all(const Library *library);
36char *sb_library_sync_status(const Library *library);38char *sb_library_sync_status(const Library *library);
37void sb_library_set_offline(const Library *library, bool offline);39void sb_library_set_offline(const Library *library, bool offline);
38void sb_library_touched(const Library *library, const char *path);40void 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);
56bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme);58bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme);
57bool sb_section_flush(const Section *section, double seconds);59bool sb_section_flush(const Section *section, double seconds);
58void sb_section_wake(const Section *section);60void sb_section_wake(const Section *section);
61void sb_section_set_author(const Section *section, const char *author);
59void sb_section_free(Section *section);62void sb_section_free(Section *section);
6063
61View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale);64View *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);
65bool sb_view_frame_pending(const View *view);68bool sb_view_frame_pending(const View *view);
66void sb_view_set_dark(View *view, bool dark);69void sb_view_set_dark(View *view, bool dark);
67bool sb_view_read_only(const View *view);70bool sb_view_read_only(const View *view);
71char *sb_view_reading(const View *view, float width);
72bool sb_view_set_reading(View *view, float width);
68bool sb_view_reload(View *view, bool discard);73bool sb_view_reload(View *view, bool discard);
69void sb_view_focus(View *view, bool focused);74void sb_view_focus(View *view, bool focused);
70void sb_view_content(View *view, float bounds[4]);75void 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 = ENGINES128 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 }
144138
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 = ENGINES140 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 }
215193
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.
634static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());612static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());
635613
614fn pooled_engine() -> TextEngine {
615 ENGINES
616 .lock()
617 .ok()
618 .and_then(|mut engines| engines.pop())
619 .unwrap_or_default()
620}
621
622fn 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.
638static OPTIONS: Mutex<Option<(bool, canvas::editor::DefaultFont)>> = Mutex::new(None);630static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont)>> =
631 LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default())));
639632
640fn options() -> (bool, canvas::editor::DefaultFont) {633fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> {
641 OPTIONS634 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}
647636
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)]
650pub extern "C" fn sb_set_snap_to_grid(on: bool) {639pub 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}
654642
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)]
660pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {648pub 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}
669655
670pub struct View {656pub 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}
680668
681impl View {669impl 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 }
762745
...@@ -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}
836850
837impl Drop for View {851impl 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.
860fn 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}
846866
...@@ -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}
920940
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)]
923pub extern "C" fn sb_view_read_only(view: &View) -> bool {943pub extern "C" fn sb_view_read_only(view: &View) -> bool {
924 view.read_only944 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)]
950pub 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)]
967pub 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}
926975
927/// Shows the page as stored after a change made elsewhere, keeping the caret; with976/// 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}
12361285
1237fn srgb(colorref: u32) -> [u8; 3] {1286pub(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 i81452 field as i8
1404}1453}
14051454
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's2//! 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.
44
5use crate::{Result, owned, report, string};5use crate::{Result, owned, report, srgb, string};
6use canvas::search::{Entry, Index, Query, Tagged};6use canvas::search::{Entry, Index, Query, Tagged};
7use notebook::{7use notebook::{
8 Remote, Replica,8 Remote, Replica,
...@@ -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};
18use std::{19use 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}
251252
253/// Catalog `path` from the top of the share holding a notebook at `root`.
254pub(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.
253struct Checked {263struct 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 concurrent283 /// 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}
297313
298fn 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();315fn one_line(title: &str) -> String {
300 [red, green, blue]316 title.replace(|char: char| char.is_control(), " ")
301}317}
302318
303fn stem(path: &str) -> String {319fn stem(path: &str) -> String {
...@@ -309,18 +325,21 @@ fn stem(path: &str) -> String {...@@ -309,18 +325,21 @@ fn stem(path: &str) -> String {
309325
310/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps326/// 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.
312fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {328fn 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 }
503525
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(self630 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 = self658 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 {
642pub(crate) struct Shared {694pub(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}
648700
649impl Shared {701impl 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}
696755
697fn unix(filetime: u64) -> i64 {756pub(crate) fn unix(filetime: u64) -> i64 {
698 (filetime / 10_000_000) as i64 - 11_644_473_600757 (filetime / 10_000_000) as i64 - 11_644_473_600
699}758}
700759
...@@ -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 formats768/// 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.
711fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {770fn 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> {
718impl Section {777impl 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 = self780 self.library
722 .library781 .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 {
731786
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 = self789 self.library
735 .library790 .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 = library829 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: conflicts848 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}
12421288
1289fn 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
1243pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char {1299pub(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}
12511307
1252fn 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 the1308/// 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 this1309/// 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 checked1310/// 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}
12781327
1279/// The notebook folder `root`, `/`-separated from the top of `share` on the server at1328/// 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}
13061352
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}
14211467
1422/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to1468/// 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)]
1475pub 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)]
1487pub 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)]
1426pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {1495pub 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}
17411813
1742/// Waits for every edit to be stored in the cache, then up to `seconds` for them to be1814/// 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}
17481820
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)]
1826pub 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)]
1751pub extern "C" fn sb_section_wake(section: &Section) {1836pub 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]
707fn 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]
719fn 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}
702726
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
66
7[features]7[features]
8smb = ["dep:smb2", "dep:tokio"]8smb = ["dep:smb2", "dep:tokio"]
9protected = ["onestore/protected", "dep:zeroize"]
109
11[dependencies]10[dependencies]
12onestore = { path = "../onestore" }11onestore = { path = "../onestore" }
...@@ -18,7 +17,14 @@ tempfile = "3"...@@ -18,7 +17,14 @@ tempfile = "3"
18sha2 = "0.11"17sha2 = "0.11"
19smb2 = { version = "=0.21.0", optional = true }18smb2 = { version = "=0.21.0", optional = true }
20tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true }19tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true }
21zeroize = { version = "1.9.0", optional = true }20zeroize = "1.9.0"
21# Queued edits of a password-protected section are sealed under a key derived from its own.
22aes-gcm = "0.11.1"
23base64 = "0.22.1"
24getrandom = "0.4.3"
25hmac = "0.13.0"
26# OneNote packages (.onepkg) are cabinet files.
27cab = "0.6"
2228
23[target.'cfg(target_vendor = "apple")'.dependencies]29[target.'cfg(target_vendor = "apple")'.dependencies]
24nix = { version = "0.31", default-features = false, features = ["fs"] }30nix = { 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
196operations and callbacks must have bounded execution times if shutdown latency196operations and callbacks must have bounded execution times if shutdown latency
197matters. Credentials belong to the factory, not the cache database.197matters. Credentials belong to the factory, not the cache database.
198198
199Creation refuses existing paths. Opening validates database integrity and replays199`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
201made meanwhile. Opening validates database integrity and replays
200the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL202the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL
201synchronization and fullfsync, each queried back: every commit is durable, and the only203synchronization and fullfsync, each queried back: every commit is durable, and the only
202file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint204file 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, as326(`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as
325OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is327OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is
326placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an328placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an
327unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still329unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle_bin` is OneNote's
330Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's
331file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still
328lists for a file gone from its folder gives way to the file an edit gives its name.332lists for a file gone from its folder gives way to the file an edit gives its name.
329Edits keep entries in the order their ordering numbers give; a rename or colour keeps333Edits keep entries in the order their ordering numbers give; a rename or colour keeps
330the numbers. Every created, renamed or moved file is placed with334the numbers. Every created, renamed or moved file is placed with
...@@ -346,13 +350,34 @@ targets are `None`....@@ -346,13 +350,34 @@ targets are `None`.
346replica, and `session::stored_pages(image)` builds its pages with each page's350replica, 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.
348352
349With the optional `protected` feature, `Notebook::unlock(path, password)`353A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
350reads a `Locked` section as `Unlocked { pages, .. }`, and354its 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
352applies 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)
353nothing of a protected section is cached or queued, a section written since357opens 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 is358each revision sealed under the section's key. The replica keeps none of it in the clear:
355`Error::Protected(PasswordMismatch)`.359its base and transactions are the file's own ciphertext, and its queued edits and payloads
360are sealed with AES-256-GCM (a random nonce each, the row's kind as associated data) under
361a key HMAC-SHA256 derives from the section's, payloads named by HMAC rather than SHA-256.
362Only the author's name, payload names and revision identities stay readable, and the
363queue 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
365background never opens a locked section.
366
367`Notebook::set_password(path, key, password)` sets, changes or removes a password as
368OneNote 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`
370puts in its place under the writers' coordination while the file's stamp holds (on a share as
371OneNote's maintenance does it: the old file aside, the new one in, the old one deleted), and
372its TOC entry takes the new identity (`TocEdit::Reidentify`). The old replica, a cache of the
373old file, is deleted, so the section's session must be closed and its edits published first;
374a section whose edits wait refuses.
375
376Another device learns of the password when it reads the notebook again (`open`, `refresh`):
377a replica of the file the protected section superseded, found by the placement the rewritten
378header keeps, is deleted when nothing waits in it. Edits that wait stay until `unlock` on that
379device queues them under the key, each page they changed coming back as a copy, as a page
380another client removed does. A session open meanwhile stops as `SyncState::Protected`.
356381
357`Section::import_page(page, author)` copies a page, usually read from another382`Section::import_page(page, author)` copies a page, usually read from another
358section, to the end of this one as one `SectionOp::Import` queued like the383section, 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
369the previous catalog, so an unreachable share never reads as an emptied394the previous catalog, so an unreachable share never reads as an emptied
370notebook.395notebook.
371396
397## Packages
398
399`package` reads and writes OneNote packages (`.onepkg`), cabinet files of a notebook's tables
400of contents and sections at their paths in its folder. `notebook_files(notebook, image)` gathers
401what OneNote 2010's Save As packs (each folder's TOC and sections, groups after, the recycle bin
402and 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
404notebook (no `guidAncestor` or `crcName`), as OneNote's are. `read` takes MSZIP or OneNote's LZX,
405refusing a path leaving the folder or more than a byte limit unpacked, and `unpack(files, root)`
406writes 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
408OneNote lists each anew beside the stale one. `section_copy` and `page_section` are Save As's
409section and page: a copy under a new identity, and a new section holding the page with its
410identity, both outside any notebook (`corpus/notebook-package`).
411
372## Snowbound's folder412## Snowbound's folder
373413
374`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which414`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which
375OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it415OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it
376through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a416on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden
377dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's417without 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 notebook418`UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
379without the folder is whole.419without the folder is whole.
380420
...@@ -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
475paths relative to the directory, `""` when the server lost count, and an error when the watch515paths relative to the directory, `""` when the server lost count, and an error when the watch
476ends with its connection.516ends with its connection.
477517
478`Client::read_asset(path, byte_limit)` reads an external payload under a read-only518An external payload on a share (`discover::Smb`) is read under a read-only share handle that excludes writes and deletion. Empty files succeed; limits,
479share handle that excludes writes and deletion. Empty files succeed; limits,
480interrupted reads and failed close never return partial bytes. This payload read519interrupted reads and failed close never return partial bytes. This payload read
481does not parse a OneStore header or acquire its reader-coordination bytes.520does not parse a OneStore header or acquire its reader-coordination bytes.
482521
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 None57 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 one30/// 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.
32const SETTLE: Duration = Duration::from_secs(1);32const SETTLE: Duration = Duration::from_secs(1);
33/// The most entries a folder listing takes.
34const ENTRIES: usize = 100_000;
3533
36/// Keeps each watched section in sync: a section is checked when a watch on the notebook's34/// 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}
6765
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))]
69pub(crate) struct Reports {68pub(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) = replica363 let Some(worker) = replica
364 .section
364 .worker365 .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}
489490
491#[cfg_attr(not(feature = "smb"), allow(dead_code))]
490impl Reports {492impl 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(
741743
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.
743fn discard(replica: &Path) {745fn 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}
3030
31/// The base image, which every cache holds.
32pub(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.
37pub(crate) fn base_stamp(connection: &Connection) -> Result<Stamp> {
38 stamp(connection, Image::Base)?.ok_or_else(missing)
39}
40
41fn 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.
32pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> {46pub(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}
5659
60impl 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)]
59pub struct Unavailable {79pub 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.toc581 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.toc593 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 result597 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};
14use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};15use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
15use std::{16use std::{
...@@ -26,6 +27,7 @@ mod base;...@@ -26,6 +27,7 @@ mod base;
26pub mod location;27pub mod location;
27mod merge;28mod merge;
28mod migrate;29mod migrate;
30pub mod package;
29mod queue;31mod queue;
30mod recovery;32mod recovery;
31mod resolve;33mod 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's100/// 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.
100pub struct Replica {102pub 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 }
130130
131 /// Opens the cache at `path`, first creating it from the image `source` reads where there131 /// 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 offline132 /// 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 }
147148
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 that152 // 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 }
238242
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 }
252252
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 }
256256
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 {
329329
330 /// The section image the queued edits leave, the unsealed ones sealed as one more330 /// 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 }
336336
337 /// Waits until the edits applied before it are written to the queue, as reads answer337 /// 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 {
342342
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 }
349348
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 }
354387
355 fn wake_sync(&self) {388 fn wake_sync(&self) {
356 wake(&self.worker);389 wake(&self.section.worker);
357 }390 }
358}391}
359392
...@@ -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}
388421
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
390fn validate(source: &[u8]) -> Result<ExGuid> {423/// space.
424fn 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}
394431
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}
406443
407/// A closed cache's base stamp and how many edits wait.444/// A closed cache's base stamp and how many edits wait.
408fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {445fn 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}
415450
416/// A closed cache's base image, if its stamp is `stamp`: the section file as it stands.451fn pending(connection: &Connection, key: Option<&Key>) -> Result<Vec<PendingEdit>> {
417fn 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
425fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
426 queue::load(connection, None)
427}453}
428454
429fn unsigned(value: i64) -> Result<u64> {455fn 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
9use crate::{
10 Error, Result,
11 session::{Notebook, lists, moved},
12};
13use onestore::{TocEdit, op::Edit, page::Page};
14use 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.
21pub type Files = Vec<(String, Vec<u8>)>;
22
23fn 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.
30pub 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
59fn 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.
68pub 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.
86pub 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
112fn 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.
119pub 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.
212pub 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.
220pub 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 bytes1//! 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.
38
4use crate::{PendingEdit, Result, signed, unsigned};9use crate::{PendingEdit, Result, signed, unsigned};
10use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
11use hmac::{Hmac, Mac};
5use onestore::{12use 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};
9use rusqlite::{Connection, OptionalExtension, params};17use rusqlite::{Connection, OptionalExtension, params};
10use sha2::{Digest, Sha256};18use sha2::{Digest, Sha256};
11use std::{io, sync::Arc};19use std::{io, sync::Arc};
20use zeroize::Zeroizing;
1221
13type Payload = Option<Arc<[u8]>>;22type Payload = Option<Arc<[u8]>>;
1423
...@@ -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}
94103
95/// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by hash;104/// A key `key` derives for `purpose`.
105fn 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
112fn 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.
117fn 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.
133fn 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`.
149fn 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.
97pub(crate) fn insert(168pub(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}
115187
116/// Replaces a stored edit's ops.188/// Replaces a stored edit's ops.
117pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> {189pub(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}
125202
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;
127fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String>)> {204/// sealed under `key` for a protected section.
205fn 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 = names236 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}
158252
159fn 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)254pub(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
268fn 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 query292 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}
202321
203/// Queued edits, oldest first; of one batch when `batch` is given.322/// Queued edits, oldest first; of one batch when `batch` is given.
204pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<PendingEdit>> {323pub(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 = connection431 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.
22pub struct Recovery {22pub 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}
2527
26impl Recovery {28impl 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 {
6374
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 }
7679
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 }
8487
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 }
8891
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 }
9295
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};
14use std::{15use 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
103104 /// 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")]
106pub 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}
111107
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}
239243
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.client281 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 }
281285
...@@ -322,9 +326,15 @@ impl Storage for Share {...@@ -322,9 +326,15 @@ impl Storage for Share {
322 .client326 .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}
326336
327const LIMITS: discover::Limits = discover::Limits {337pub(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 claims436 .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 }
452459
...@@ -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 }
550558
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 }
561565
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 }
573573
574 /// A folder's TOC path and file identity, creating the TOC when the folder has none574 /// 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 }
10531053
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, as1104 /// 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's1105 /// 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(sections1193 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 }
11701217
1171 /// The pages of a password-protected section: nothing is cached, and the decoded1218 /// 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 document1233 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 }
11941249
1195 /// Applies `edit` to pages of `unlocked` and stores it under the section's key, straight1250 /// 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 written1251 /// 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 }
12221385
1223 /// Where the replica of the section at catalog `path` lives, in the notebook's1386 /// 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 }
12701427
1271 /// Keeps the sections of a mounted notebook in sync while they are not open1428 /// 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}
13611549
...@@ -1411,7 +1599,18 @@ pub struct StoredPage {...@@ -1411,7 +1599,18 @@ pub struct StoredPage {
1411pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> {1599pub 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`.
1606pub 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
1613fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
1415 Ok(document1614 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}
14621661
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`.
1464fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {1663pub(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}
15101709
1710/// Deletes the closed replica at `replica`, with the files SQLite keeps beside it.
1711fn 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.
1512pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {1724pub(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 }
16401852
1641 /// Opens the canonical section `file` through the replica `replica` names for its document1853 /// 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)]
2233mod tests;
crates/notebook/src/session/tests.rs created+123
...@@ -0,0 +1,123 @@
1use 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.
5struct Racing {
6 inner: Directory,
7 revision: Mutex<Option<(String, Transaction)>>,
8}
9
10impl 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
20impl 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]
73fn 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}
169173
170#[test]174#[test]
crates/notebook/src/smb/mod.rs+29-2
...@@ -331,7 +331,7 @@ impl Client {...@@ -331,7 +331,7 @@ impl Client {
331331
332 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010332 /// 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 {
389389
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 }
495495
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"]
464fn live_storage_inspection() {464fn 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::WouldBlock492 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}
8585
86fn state(connection: &Connection) -> Result<State> {86fn 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 = connection88 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> {
158impl Replica {157impl 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 }
163162
164 /// The last observed remote image. Observation alone does not acknowledge any pending163 /// 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 }
176172
177 /// Publishes the oldest unpublished batch, rebasing the queue first when the remote173 /// 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 }
404405
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}
529530
530pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> {531pub(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) = connection537 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 = self168 let mut owner = self
169 .section
169 .worker170 .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};
12use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};13use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
13use std::{14use std::{
...@@ -96,18 +97,19 @@ impl Request {...@@ -96,18 +97,19 @@ impl Request {
96 }97 }
97}98}
9899
99type Worker = Arc<Mutex<Weak<Signal>>>;
100
101/// The section thread as the replica sees it. Rereading the section after the queue was100/// 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 one101/// 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 the102/// 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.
105pub(crate) struct Thread {104pub(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}
112114
113impl Thread {115impl Thread {
...@@ -148,16 +150,14 @@ impl Thread {...@@ -148,16 +150,14 @@ impl Thread {
148}150}
149151
150/// Starts the section thread once the queue opens.152/// Starts the section thread once the queue opens.
151pub(crate) fn spawn(153pub(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 {
237fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {237fn 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}
287289
288impl<'a> Working<'a> {290impl<'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 every307 // 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 } => self360 } => 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 false392 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 !written401 !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 = self418 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 = self431 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> {
503504
504 /// Writes the accepted edits in one transaction, then answers their senders; false505 /// 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 local597/// 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.
603fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<ExGuid>> {600fn 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> = merged652 let pages: BTreeMap<ExGuid, Page> = merged
654 .conflicts653 .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
743fn replay<'a>(742fn 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 section764 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>(
781780
782/// The image the queue leaves, its unsealed edits sealed as one more revision; that781/// 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.
784pub(crate) fn image(connection: &Connection) -> Result<Vec<u8>> {783pub(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}
790789
790/// A section image, a password-protected one under `key`.
791pub(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.
792pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> {799pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> {
793 let row: Option<(i64, String)> = connection800 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}
7979
crates/notebook/tests/package.rs created+265
...@@ -0,0 +1,265 @@
1//! OneNote packages and the copies Save As writes (`corpus/notebook-package`).
2
3use notebook::{package, session::Notebook};
4use std::path::Path;
5
6fn 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.
11fn 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.
31fn 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]
56fn 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
155fn 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]
169fn 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]
191fn 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]
205fn 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]
245fn 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.
23
3use notebook::{discover::SectionState, session::Notebook};4use notebook::{EditStatus, Replica, discover::SectionState, session::Notebook};
5use onestore::{
6 Arena, ExGuid, Section,
7 op::{Edit, Op, PageOp},
8 page::{Page, PageObject},
9 protected::{Error, Key, rekey},
10};
4use std::path::Path;11use std::path::Path;
512
6/// A locked section is discovered as such and reads with its password, page by page.13#[path = "support/server.rs"]
7#[test]14mod server;
8fn a_locked_section_unlocks_for_reading() {15use server::Server;
9 let root = Path::new(concat!(16
10 env!("CARGO_MANIFEST_DIR"),17fn 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.
25fn 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.
34fn 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.
41fn 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
53fn 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
68fn 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.
83fn 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]
98fn 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::PasswordMismatch109 ));
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}
30126
31/// An unlocked page is edited as ops stored under the section's key, then reads back with127/// 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 for128/// the section's key, and publish into the file under it.
33/// a cold reopen in OneNote.
34#[test]129#[test]
35fn an_unlocked_page_is_saved_under_the_section_key() {130fn 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]
219fn 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]
268fn 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 = notebook278 assert!(plain.exists());
53 .catalog()279 let identity = notebook.catalog().sections[0].file_id;
54 .sections280
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]
332fn 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.
371fn 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]
383fn 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 notebook402}
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]
407fn 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]
433fn 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.
479fn 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`.
492fn 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]
511fn 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 notebook636 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::{
5use notebook::{Remote, Replica};5use notebook::{Remote, Replica};
6use onestore::{6use 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};
15use std::{io, sync::LazyLock};12use std::{io, sync::LazyLock};
1613
...@@ -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 the51/// 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.
56pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {53pub 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 = page56 let at = page
96 .objects57 .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 id63 id
102}64}
10365
crates/onestore/Cargo.toml+6-9
...@@ -4,9 +4,6 @@ version = "0.1.0"...@@ -4,9 +4,6 @@ version = "0.1.0"
4edition = "2024"4edition = "2024"
5publish = false5publish = false
66
7[features]
8protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"]
9
10[dependencies]7[dependencies]
11base64 = "0.22.1"8base64 = "0.22.1"
12bumpalo = "3.20.3"9bumpalo = "3.20.3"
...@@ -14,12 +11,12 @@ serde_json = "1.0.151"...@@ -14,12 +11,12 @@ serde_json = "1.0.151"
14md5 = "0.8.1"11md5 = "0.8.1"
15getrandom = "0.4.3"12getrandom = "0.4.3"
16serde = { version = "1.0.229", features = ["derive"] }13serde = { version = "1.0.229", features = ["derive"] }
17aes = { version = "0.9.3", features = ["zeroize"], optional = true }14aes = { version = "0.9.3", features = ["zeroize"] }
18cbc = { version = "0.2.1", features = ["zeroize"], optional = true }15cbc = { version = "0.2.1", features = ["zeroize"] }
19roxmltree = { version = "0.21.1", optional = true }16roxmltree = "0.21.1"
20sha1 = { version = "0.11.0", features = ["zeroize"], optional = true }17sha1 = { version = "0.11.0", features = ["zeroize"] }
21subtle = { version = "2.6.1", optional = true }18subtle = "2.6.1"
22zeroize = { version = "1.9.0", optional = true }19zeroize = "1.9.0"
2320
24[target.'cfg(target_os = "macos")'.dependencies]21[target.'cfg(target_os = "macos")'.dependencies]
25nix = { version = "0.31.3", default-features = false, features = ["fs"] }22nix = { 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 |
7276
73Edits enter a kept-open `Section` as ops and leave as one appended revision per77Edits enter a kept-open `Section` as ops and leave as one appended revision per
74changed space; only section and page creation, imports and opening files handle78changed 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,
78protected objects and split surrogate pairs are82protected objects and split surrogate pairs are
79rejected before writing. Appended snapshots cap revision dependency depth at 51283rejected before writing. Appended snapshots cap revision dependency depth at 512
80while retaining historical revisions. TOC snapshots can remap encoded CompactIDs84while retaining historical revisions. TOC snapshots can remap encoded CompactIDs
81without changing their resolved references. Password-protected85without changing their resolved references. A password-protected section opens with
82sections retain their encrypted structure and payloads. With the optional86`Section::unlock` under the `Key` its password opens, and every revision it seals names
83`protected` feature, `protected::UnlockedSection` opens native OneNote 201087the key (MS-ONESTORE 2.5.19 asks that of each revision; OneNote names it only in those
84AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect88without a dependency, and reads both). `Section::open` refuses one. Incorrect
85passwords, unsupported protection profiles and work-limit failures remain distinct.89passwords, unsupported protection profiles and work-limit failures remain distinct
86The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them90(`protected::Error`).
87under the section's key (fresh IV per object, payloads under the file IV); `Section`
88rejects protected sections.
89`PageCreation::new` appends, or inserts before the first page space of an existing91`PageCreation::new` appends, or inserts before the first page space of an existing
90series. `Some("")` creates an empty title field; `None` omits the title node.92series. `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 that93`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
331and occurrence to withhold. The captured trace and result files are the regression333and occurrence to withhold. The captured trace and result files are the regression
332oracle; replaying the native experiments requires the supplied Windows/share setup.334oracle; replaying the native experiments requires the supplied Windows/share setup.
333335
334## Protected inspection336## Password-protected sections
337
338OneNote 2010 wraps an AES-128 key in Office's Agile password encryption (MS-OFFCRYPTO:
339SHA-1 of a 16-byte salt and the UTF-16LE password, then 100,000 rounds of SHA-1 over the
340round number and the hash; three block keys decrypt, with the salt as IV, the verifier
341input, 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
343encryption-data container every revision names. Each property object is stored as its
344reference streams, a length, a random IV and the CBC encryption of a padding count, the
345property bytes and random padding; read-only objects hash the clear bytes zero-padded to
3468 bytes. Payloads are their length and bytes, randomly padded, under CBC with the IV
347SHA-1(key data salt, block 0). Setting, changing or removing a password writes the section
348anew, as OneNote does, under fresh identities so that no cache confuses the old file's
349objects or payloads with the new ones (`corpus/protected-sections`).
335350
336```rust,no_run351```rust,no_run
337# #[cfg(feature = "protected")]352use onestore::{Arena, Section, protected::{Key, rekey}};
338# fn example() {353# fn example(image: Vec<u8>, password: &str) -> Result<(), Box<dyn std::error::Error>> {
339use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}};354let key = Key::open(&image, password)?;
340# fn inspect(bytes: &[u8], password: &str) -> Result<(), Box<dyn std::error::Error>> {355let arena = Arena::default();
341let store = Store::parse(bytes)?;356let mut section = Section::unlock(&arena, image.clone(), &key)?;
342let index = RevisionIndex::parse(&store)?;357assert!(!section.pages()?.is_empty());
343let unlocked = UnlockedSection::open(&index, password, Limits::default())?;358let changed = rekey(&image, Some(&key), Some(&Key::new("another password")?))?;
344let document = unlocked.document()?;359# drop(changed);
345assert!(!document.pages()?.is_empty());
346drop(document);
347drop(unlocked);
348# Ok(()) }360# Ok(()) }
349# }
350```361```
351362
352This example requires `features = ["protected"]`. The owner retains no password;363A `Key` holds no password; its key is cleared when its last clone drops. A `Section`
353its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed364decodes into its `Arena`, which is not cleared; drop both when the section locks.
354strings, serialized models and exports have their own lifetimes. These copies are365`UnlockedSection` owns its decoded buffers and clears them on drop; the views it lends
355plaintext, and dropping the unlock owner does not clear them. CBC has no general366cannot outlive it, while copies of parsed strings, serialized models and exports are
367plaintext with lifetimes of their own. CBC has no general
356ciphertext-authentication guarantee; native read-only hashes and model validation368ciphertext-authentication guarantee; native read-only hashes and model validation
357check the corresponding structure. Internal payloads are decoded; external payload369check the corresponding structure. Internal payloads are decoded; external payload
358references remain references, and their protected decoding is not implemented.370references remain references, and their protected decoding is not implemented.
359371
360For a deliberate plaintext diagnostic export, build the notebook exporter with372For a deliberate plaintext diagnostic export, run the notebook exporter
361`--features protected` and pass `--password-file PATH` after the source and optional373(`examples/document`) with `--password-file PATH` after the source and optional
362new output directory. The file contains exact UTF-8 password bytes; no newline is374new output directory. The file contains exact UTF-8 password bytes; no newline is
363removed or Unicode normalization applied. The exporter creates protected exports375removed or Unicode normalization applied. The exporter creates protected exports
364under an owner-only directory on Unix and reports protected external payloads as376under an owner-only directory on Unix and reports protected external payloads as
crates/onestore/examples/scratch_dump.rs deleted-53
...@@ -1,53 +0,0 @@
1use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value};
2use std::{env, fs};
3
4fn 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)]
7use crate::{7use crate::{RevisionIndex, Store, document::Document, write::chain_depth};
8 RevisionIndex, Store,
9 document::{Document, Space},
10 write::chain_depth,
11};
12use bumpalo::Bump;8use bumpalo::Bump;
13use std::{9use 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 = document46 let view = document
51 .spaces47 .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}
257265
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))]
270pub 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 header305/// 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 which307/// 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 }
359406
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 None616 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.
633fn 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_chunk676 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.
694pub(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}
669719
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}
4843
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
5use crate::{Error, Header, create::placement, write::fresh_guid};
6
7type 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.
11pub 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.
27pub 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)]
35mod 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;
10mod files;10mod files;
11mod flush;11mod flush;
12mod formatting;12mod formatting;
13mod identity;
13mod insertion;14mod insertion;
14mod objects;15mod objects;
15pub mod op;16pub mod op;
...@@ -18,7 +19,6 @@ pub mod page;...@@ -18,7 +19,6 @@ pub mod page;
18mod pages;19mod pages;
19mod paragraph;20mod paragraph;
20mod properties;21mod properties;
21#[cfg(feature = "protected")]
22pub mod protected;22pub mod protected;
23mod revisions;23mod revisions;
24mod section;24mod section;
...@@ -33,10 +33,11 @@ mod write;...@@ -33,10 +33,11 @@ mod write;
3333
34pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place};34pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place};
35#[cfg(any(unix, windows))]35#[cfg(any(unix, windows))]
36pub use commit::{confirm_file, place_file, read_file, read_file_limited};36pub use commit::{confirm_file, place_file, read_file, read_file_limited, supersede_file};
37pub use create::{create_empty_section, create_section, create_table_of_contents};37pub use create::{create_empty_section, create_section, create_table_of_contents};
38pub use files::FileDataReference;38pub use files::FileDataReference;
39pub use formatting::{FONT_SIZES, TextAttribute};39pub use formatting::{FONT_SIZES, TextAttribute};
40pub use identity::{place_image, reidentify};
40pub(crate) use insertion::Insertion;41pub(crate) use insertion::Insertion;
41pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};42pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
42pub use outline::OutlineEdit;43pub 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> {
190190
191impl<'a> RevisionIndex<'a> {191impl<'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}
1717
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.
19fn payload_reference(guid: [u8; 16]) -> String {19pub(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.kind822 } = &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: root853 color: root
849 .extra854 .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}
9191
92/// Password-hash rounds OneNote 2010 writes.
93const SPINS: u32 = 100_000;
94/// MS-OFFCRYPTO's block keys for the verifier input, its hash and the wrapped key.
95const VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79];
96const VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e];
97const 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.
101fn 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.
118fn 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.
127fn 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
92impl Key {134impl 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 }
191269
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 }
248326
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 output341 Ok(output)
260 }342 }
261343
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 wrappers3//! 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 no4//! salt). Unknown wrappers stay opaque. CBC authenticates nothing; read-only hashes and
5//! general ciphertext authentication; native read-only hashes and model checks5//! model checks catch structural damage, not every change to content.
6//! detect structural inconsistencies, not arbitrary changes to all content.
76
8mod crypto;7mod crypto;
98
10use crate::{9use 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};
13use bumpalo::Bump;
15use std::{14use std::{
15 cell::RefCell,
16 collections::{BTreeMap, BTreeSet},16 collections::{BTreeMap, BTreeSet},
17 fmt,17 fmt,
18 sync::Arc,
18};19};
19use zeroize::Zeroizing;20use zeroize::Zeroizing;
2021
21type Result<T> = std::result::Result<T, Error>;22type Result<T> = std::result::Result<T, Error>;
23type Format<T> = std::result::Result<T, crate::Error>;
2224
23#[derive(Debug)]25#[derive(Debug)]
24pub enum Error {26pub 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}
3638
39impl 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
50impl 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
37impl fmt::Display for Error {62impl 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}
81102
82struct 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_fail108/// ```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> {
103pub struct UnlockedSection<'a> {117pub 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}
110123
111impl<'a> UnlockedSection<'a> {124impl<'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 }
119132
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}
301288
302impl 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 payloads290/// 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> {292pub 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();298impl 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 } => {304impl 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 &bytes307 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 copy321 })
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<_> = self329 pub(crate) fn metadata(&self) -> &[u8] {
343 .files330 &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();338pub 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 bytes340 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}
406446
407impl 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 and448fn 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.
466fn 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.
492pub(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.
501pub(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
511impl<'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 .spaces519 .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<_> = edited524 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.keys541 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}
472575
473impl 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}
479580
480impl 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 }
488584
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
596impl crate::write::Protection for Opened<'_> {
489 fn stored(&self, clear: &[u8]) -> Option<&[u8]> {597 fn stored(&self, clear: &[u8]) -> Option<&[u8]> {
490 self.objects598 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 }
495600
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 }
510612
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.
623fn 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}
5255
53#[derive(Default)]56#[derive(Default)]
...@@ -139,9 +142,17 @@ struct Open<'a> {...@@ -139,9 +142,17 @@ struct Open<'a> {
139impl<'a> Section<'a> {142impl<'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 }
146157
147 /// Opens an earlier state of a section: each object space in `revisions` at the revision158 /// 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 revisions168 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> {
170181
171 /// `open`, each object space at the revision `revision` chooses in the first image where it182 /// `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 image183 /// 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 = space230 let history = space
206 .labels231 .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 }
264303
...@@ -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}
10241067
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`,
1026fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> {1069/// a protected section's objects decoded under `unlocked`.
1027 ResolvedRevision {1070fn bind<'a>(
1028 roots: resolved.roots,1071 arena: &'a Arena,
1029 objects: resolved1072 unlocked: Option<&Opened<'a>>,
1030 .objects1073 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 in1076 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.
1110fn current(_: &ExGuid, space: &crate::ObjectSpace<'_>) -> Option<ExGuid> {
1111 space.labels.get(&(ExGuid::default(), 1)).copied()
1056}1112}
10571113
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 }
11031159
crates/onestore/src/section/versions.rs+19-32
...@@ -6,7 +6,7 @@...@@ -6,7 +6,7 @@
66
7use super::{Frozen, Open, Section, SpaceState, changed, one_page};7use super::{Frozen, Open, Section, SpaceState, changed, one_page};
8use crate::{8use 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.segments279 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 }
307294
308 /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the295 /// 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}
3945
40fn component(name: &str) -> bool {46fn 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])> = updates234 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 {
487487
488pub(crate) enum RevisionEdit {488pub(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}
504496
505impl PropertyObject {497impl PropertyObject {
...@@ -801,14 +793,16 @@ impl PropertyObject {...@@ -801,14 +793,16 @@ impl PropertyObject {
801 }793 }
802}794}
803795
804/// A password-protected section's unlocked view, through which its revisions are796/// 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.
806pub(crate) trait Protection {797pub(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}
813807
814/// The global-ID entries a group of objects declared together uses: each object's own808/// 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}
12861280
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")]
1300pub(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}
13081289
1309#[cfg(any(test, feature = "protected"))]1290#[cfg(test)]
1310fn publish(1291fn 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<()> {
13321311
1333/// The written image, unchecked: `check` follows once the caller has released whatever1312/// 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)]
1336fn build(1315fn 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}
13531327
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! {
1377pub(crate) fn build_on(1351pub(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}
15201451
...@@ -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}
15291460
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"))]
1532pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> {1462pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> {
1533 store1463 store
1534 .lists1464 .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]> {
15411471
1542/// Writes the live objects of each `edited` revision that differ from its space's active1472/// 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)]
1546pub(crate) fn squash(1476pub(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<_> = payloads1482 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}
15921518
1519/// A whole revision for `rewrite`, with the labels it is current under.
1520pub(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.
1527pub(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.
1594pub(crate) struct Sealing<'r, 'a> {1625pub(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 u641695 self.base.length + self.append.len() as u64
1665 }1696 }
16661697
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 its2312/// 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 stay2313/// 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)]
2282pub(crate) fn check_transaction(2316pub(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()? != rid2485 || c.exguid()? != rid
2450 || c.exguid()? != dependency2486 || 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 == 0xc52582 || (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
3use onestore::{1use 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};
9use std::{fs, path::Path};8use std::{fs, path::Path};
109
11#[test]10/// The native fixtures with their passwords.
12fn known_passwords_open_independent_native_fixtures() {11fn 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.
30fn 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
39fn objects(pages: &[(ExGuid, Page)]) -> Vec<&Vec<onestore::page::PageObject>> {
40 pages.iter().map(|(_, page)| &page.objects).collect()
41}
42
43#[test]
44fn 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 document53 document
52 .spaces54 .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}
7076
71#[test]77#[test]
72fn limits_and_password_bytes_are_explicit() {78fn 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}
121127
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.129fn 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.
171fn 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]
125fn a_protected_page_edit_is_stored_under_the_section_key() {185fn 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]
240fn a_native_revision_inherits_the_key_of_its_dependency() {252fn 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();270fn 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]
358fn 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]
371fn 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.
32ureq = { version = "3.4", default-features = false, features = ["rustls"] }32ureq = { version = "3.4", default-features = false, features = ["rustls"] }
33ring = "0.17"33ring = "0.17"
34# Clears passwords typed into the password dialogs.
35zeroize = "1.9.0"
3436
35[target.'cfg(target_os = "macos")'.dependencies]37[target.'cfg(target_os = "macos")'.dependencies]
36libc = "0.2"38libc = "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/sh1#!/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.
4set -eu4set -eu
5root=$(cd "$(dirname "$0")/../../.." && pwd)5root=$(cd "$(dirname "$0")/../../.." && pwd)
6here="$root/crates/snowbound/linux"6here="$root/crates/snowbound/linux"
7target="$root/target"7target="$root/target"
8tools="$target/zig"8mkdir -p "$target/dist"
9mkdir -p "$tools" "$target/dist"
10command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; }
11
12printf '#!/bin/sh\nexec zig ar "$@"\n' > "$tools/ar"
13chmod +x "$tools/ar"
149
15[ $# -gt 0 ] || set -- x86_64 aarch6410[ $# -gt 0 ] || set -- x86_64 aarch64
16for arch do11for arch do
17 triple=$arch-unknown-linux-gnu12 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
24for arg do
25 shift
26 case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac
27done
28exec zig cc -target $arch-linux-gnu.2.17 "\$@"
29EOF
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
3715
38 name=snowbound-linux-$arch16 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};
19use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};19use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};
20use winit::{20use winit::window::{CursorIcon, Window};
21 raw_window_handle::{HasWindowHandle, RawWindowHandle},
22 window::{CursorIcon, Window},
23};
2421
25static PRETEND: AtomicBool = AtomicBool::new(false);22static PRETEND: AtomicBool = AtomicBool::new(false);
2623
...@@ -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");
24pub const FONT_COLOR: &[&str] = art!("icons/font-color");24pub const FONT_COLOR: &[&str] = art!("icons/font-color");
25pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");25pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");
26pub const INDENT: &[&str] = art!("icons/indent");26pub const INDENT: &[&str] = art!("icons/indent");
27pub const INFO: &[&str] = art!("icons/info");
27pub const ITALIC: &[&str] = art!("icons/italic");28pub const ITALIC: &[&str] = art!("icons/italic");
28pub const LINK: &[&str] = art!("icons/link");29pub const LINK: &[&str] = art!("icons/link");
29pub const NOTEBOOK: &[&str] = art!("icons/notebook");30pub 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");
46pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");47pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");
47pub const TABLE: &[&str] = art!("icons/table");48pub const TABLE: &[&str] = art!("icons/table");
48pub const UNDERLINE: &[&str] = art!("icons/underline");49pub const UNDERLINE: &[&str] = art!("icons/underline");
50pub const WARNING: &[&str] = art!("icons/warning");
49pub const UNDO: &[&str] = art!("icons/undo");51pub const UNDO: &[&str] = art!("icons/undo");
50pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");52pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
51pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");53pub 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::Help1117 | 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 !modal1154 !modal
1076 && self1155 && 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::NewPage1165 // The recycle bin takes no new pages.
1087 | Id::NewSubpage1166 Id::NewPage | Id::NewSubpage => {
1088 | Id::CopyPageLink1167 enabled(!modal && session.is_some() && !self.in_recycle_bin())
1089 | Id::Find1168 }
1090 | Id::Search1169 Id::CopyPageLink | Id::Find | Id::Search | Id::ExportPdf | Id::Print => {
1091 | Id::ExportPdf1170 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.
37pub const APP_ID: &str = "net.paperclover.snowbound";37pub const APP_ID: &str = "net.paperclover.snowbound";
38const TEMPLATE: &str = include_str!("../linux/snowbound.desktop");38const 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.
40const MIME_TYPES: &str = include_str!("../linux/onenote.xml");40const 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.
42const ICON: &[u8] = include_bytes!("../linux/snowbound.png");42const 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 }
2525
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 }
3033
31 /// Version `version` of page `space` as the editor shows it: read-only, what it changed34 /// 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 .section169 .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>)> = session214 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 = library234 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>)> = section237 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}
271248
249/// Deletes every page version `section` holds.
250fn 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 out265/// The readable sections at catalog path `folder` and in the groups inside it, leaving out
273/// the recycle bin.266/// the recycle bin.
274fn sections(library: &Library, folder: &str) -> Vec<String> {267fn 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}
3236
33pub fn lone_section(37pub 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 = section586 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 {
78fn title(id: Command) -> String {78fn 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}
8484
...@@ -430,13 +430,6 @@ impl crate::State {...@@ -430,13 +430,6 @@ impl crate::State {
430 taken430 taken
431 }431 }
432432
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};
5use notebook::session::{Background, Notebook, Section};5use notebook::session::{Background, Notebook, Section};
6use notebook::sidecar::themes::Themes;6use notebook::sidecar::themes::Themes;
7use notebook::smb::{Client, Credentials};7use notebook::smb::{Client, Credentials};
8use onestore::protected::Key;
8use std::{9use 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 url165 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}
234235
235/// `text` escaped for an address, `/` kept.236/// `bytes` escaped for an address, outside RFC 3986's unreserved set and `/`.
236fn encode(text: &str) -> String {237pub(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}
322326
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)]
330pub struct Keys(Mutex<std::collections::HashMap<[u8; 16], (Key, Instant)>>);
331
323impl Library {332impl 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 a351 /// 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 the352 /// 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 with353 /// 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 login361 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 }
383400
...@@ -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 }
425438
...@@ -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 }
593604
...@@ -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 }
611615
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 }
628624
629 /// Opens the section at catalog `path`; on a share, through a replica named by its625 /// 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) = self659 if let Some(kept) = self
659 .kept660 .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 {
856855
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 }
871864
872 /// The first readable section, searching groups after sections, as OneNote opens a865 /// 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 }
892876
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 }
907886
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 keeps892 /// 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 folder900 })
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 }
932902
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}
941994
942/// The share folder a notebook opened from its server's address lives in, as its location995/// 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}
9501003
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.
953pub fn key(location: &str, path: &str) -> String {1005pub 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}
9731025
1026/// `folder` and the groups below it, breadth first, going into those `enter` takes.
1027pub 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.
1038pub 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.
974pub fn recycle_bin(path: &str) -> bool {1044pub fn recycle_bin(path: &str) -> bool {
975 path.rsplit('/').next() == Some("OneNote_RecycleBin")1045 path.rsplit('/').next() == Some("OneNote_RecycleBin")
976}1046}
9771047
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.
982fn read_tag_art(notebook: &Notebook) -> TagArt {1049fn 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 read1066 read
1000}1067}
10011068
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.
1002fn local_background(1072fn 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.
1062pub fn section_name(path: &str, name: &Option<String>) -> String {1132pub 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}
10991181
...@@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool {...@@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool {
11111193
1112/// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a1194/// 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.
1115pub fn locate(path: &Path) -> Located {1197pub fn locate(path: &Path) -> Located {
1116 let extension = path1198 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() / scale72 (units * scale).round() / scale
73}73}
7474
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.
79pub fn follow_appearance(_: &Window, _: Theme) {}76pub fn follow_appearance(_: &Window, _: Theme) {}
8077
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.
81pub fn cuts_corners() -> bool {81pub 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}
619619
620/// Keeps the window's client area at least `size` points.
621pub 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
620pub fn zoom(window: &Window) {625pub 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) {
773778
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.
776fn file_uri(path: &std::path::Path) -> String {781pub(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 }790pub(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 uri792 (!symbol.is_null())
793 .then(|| unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) })
788}794}
789795
790pub fn cache_dir() -> Option<PathBuf> {796pub 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}
11261132
1133/// The path a file dialog answered; none when cancelled or when no tool could ask.
1134fn 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`.
1128pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {1145pub 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}
11531163
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 when1220/// 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.
1212pub fn pick_notebook(title: &str) -> Option<PathBuf> {1222pub 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}
12351238
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}
12601256
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 {
3737
38pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface};38pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface};
3939
40static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();40static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
41static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();41static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
42static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();42static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
43static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();43static 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}
8080
81fn ns_window(window: &Window) -> Retained<AnyObject> {81/// The view winit draws the window's content in.
82fn 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 }91pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> {
92 unsafe { msg_send_id![&ns_view(window), window] }
91}93}
9294
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}
109111
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.
113pub fn follow_appearance(_: &Window, _: winit::window::Theme) {}113pub fn follow_appearance(_: &Window, _: winit::window::Theme) {}
114114
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.
115pub fn cuts_corners() -> bool {117pub 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}
883880
881/// Keeps the window's client area at least `size` points.
882pub 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 its886/// 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 the887/// 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.
900pub fn install_text_input(window: &Window) {902pub 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::Era995 let units = NSCalendarUnit::Era
1003 | NSCalendarUnit::Year996 | NSCalendarUnit::Year
1004 | NSCalendarUnit::Month997 | 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] {
10531046
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.
1055pub fn date_text(filetime: u64) -> [String; 2] {1048pub 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.
1054fn 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}
10631059
...@@ -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.
1070pub fn short_date(filetime: u64) -> String {1066pub 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!(
12311225
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 }
12361230
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 for1302 // 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.
1393pub fn clear_marked_text(window: &Window) {1388pub 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;
40mod platform;40mod platform;
41mod prefetch;41mod prefetch;
42mod print;42mod print;
43mod 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")]
47mod printer;46mod printer;
47mod properties;
48mod protection;
48mod recording;49mod recording;
50mod recycle;
49mod rename;51mod rename;
52mod save_as;
50mod screenshot;53mod screenshot;
51mod search;54mod search;
52mod server;55mod server;
...@@ -64,6 +67,8 @@ mod tags;...@@ -64,6 +67,8 @@ mod tags;
64mod templates;67mod templates;
65mod themes;68mod themes;
66mod undo;69mod undo;
70mod unpack;
71mod unread;
67mod update;72mod update;
68mod video;73mod video;
69mod watch;74mod watch;
...@@ -106,6 +111,12 @@ use winit::{...@@ -106,6 +111,12 @@ use winit::{
106const TITLE: f32 = 38.0;111const TITLE: f32 = 38.0;
107/// Space between toolbar groups.112/// Space between toolbar groups.
108const GAP: f32 = 6.0;113const GAP: f32 = 6.0;
114/// A tool face's room beside its buttons.
115const FACE_PAD: f32 = 5.0;
116/// How far a tool face stands above and below its buttons.
117const FACE_RISE: f32 = 3.0;
118/// Space between groups on faces of their own.
119const FACE_GAP: f32 = 2.0;
109const TAB_ROW: f32 = 28.0;120const 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.
111const FRAME: f32 = 6.0;122const FRAME: f32 = 6.0;
...@@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0;...@@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0;
115const MIN_SIZE: [f32; 2] = [600.0, 400.0];126const 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.
117const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];128const 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.
120const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];130const 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's131/// 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 was746 /// 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 = notebooks975 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.commands1587 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 still1918 // 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.window1966 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 = self2836 let found = self.search.found_in(&session.key());
2753 .search2837 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 !self3202 if !self
3117 .view3203 .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 .session3237 .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's3241 // 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 }
33193390
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() >= deadline3578 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> = self3671 let reported: Vec<(Arc<Library>, Vec<String>)> = (self.notebooks.iter())
3579 .notebooks3672 .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.renderer4076 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 }
39904095
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 }
40254127
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}
43374442
4338/// The page list's rows, a page's conflict pages beneath it while shown and the pages a4443/// 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, which4444/// 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)]
4342fn page_rows(4447fn 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: renaming4566 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 ..*section4600 ..*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 .04635 .0
4548 .clicked4636 .clicked;
4549 && !selected4637 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 height4643 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}
50125105
5106/// Opens the trailing row of a dialog, whose buttons then stand at its right.
5107fn 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.
5014fn name(ui: &mut Ui, id: Id, label: &str) {5127fn 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}
51815294
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.
5296fn 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.
5302fn 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.
5183fn divider(ui: &mut Ui, theme: &Theme) {5318fn 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);
53125450
5313impl ApplicationHandler<UserEvent> for App {5451impl 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.state5506 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}
56805774
5681/// Feeds a development script to the window from another thread, one command per line5775/// 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`, `type5776/// 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`.
5685fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {5780fn 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}
5967
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 }
235243
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 }
252261
...@@ -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 candidate660 found.map_or_else(Vec::new, |folder| {
667 .sections661 (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();
11931203
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 with3//! 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.
55
6use crate::video;6use crate::{platform::symbol, video};
7use std::{7use 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 {
50unsafe impl Send for Gst {}50unsafe impl Send for Gst {}
51unsafe impl Sync for Gst {}51unsafe impl Sync for Gst {}
5252
53/// The function `name` in `library`, of C signature `F`.
54unsafe 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.
60fn gst() -> Option<&'static Gst> {54fn 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}
8680
87/// `path` as a `file://` URI, every byte outside the unreserved set escaped.
88fn 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.
102struct Pipeline {82struct 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};
10use onestore::{ExGuid, PageEdit};11use onestore::{ExGuid, PageEdit};
11use std::sync::Arc;12use 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}
114126
115/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section127/// 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 actions345 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 None639 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 None764 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 = self784 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 breadth1028/// `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.
917pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> {1030pub(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}
9281035
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}
215241
...@@ -241,6 +267,10 @@ fn sizes() -> Id {...@@ -241,6 +267,10 @@ fn sizes() -> Id {
241 id().child("sizes")267 id().child("sizes")
242}268}
243269
270fn lock_times() -> Id {
271 id().child("lock-times")
272}
273
244fn font_colors() -> Id {274fn 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}
681718
719fn 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
682fn user_name_field(state: &mut State, options: &mut Options) {748fn 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<_> = session934 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 }
4040
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 }
145150
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 }
148148
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, which157 /// 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..end414 start..end
420}415}
421416
422/// Every readable section of `library`, catalog path and name, a folder's sections before417/// 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.
424fn notebook_tabs(library: &Library) -> Vec<(String, String)> {419fn 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 library422 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 tabs438 tabs
442}439}
443440
441/// The pages of `pages` a section's or notebook's PDF prints: all but untitled ones holding
442/// only their date.
443fn 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.
445fn file_name(title: &str) -> String {451pub(crate) fn file_name(title: &str) -> String {
446 let name: String = title452 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
5use crate::{Command, Library, State, Theme, art, manage::Structure};
6use accesskit::Role;
7use std::{error::Error, sync::Arc, time::Duration};
8use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px};
9use winit::keyboard::NamedKey;
10use zeroize::Zeroizing;
11
12const WIDTH: f32 = 440.0;
13
14/// What Options' "Lock password protected sections after…" offers, in minutes, as
15/// OneNote 2010's list does.
16pub 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.
31pub struct Locked {
32 pub library: Arc<Library>,
33 pub path: String,
34}
35
36/// A password dialog on a section.
37pub struct Asking {
38 library: Arc<Library>,
39 path: String,
40 dialog: Dialog,
41}
42
43enum 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
56fn id() -> Id {
57 Id::ROOT.child("password")
58}
59
60fn field(index: usize) -> Id {
61 id().child(("field", index))
62}
63
64/// The locked page's notice, which a click or Enter unlocks.
65fn notice() -> Id {
66 Id::ROOT.child("locked-notice")
67}
68
69fn section_name(path: &str) -> &str {
70 let name = path.rsplit('/').next().unwrap_or(path);
71 name.strip_suffix(".one").unwrap_or(name)
72}
73
74impl 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.
639fn 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
6use crate::{Command, Library, Loaded, State, filetime, library, menus, notify, read_session};
7use onestore::{
8 ExGuid,
9 op::{Edit, Op, SectionOp},
10};
11use std::{error::Error, sync::Arc};
12use ui::{Axis, Spec, Ui, children, fill, fit};
13
14/// The bin's folder, a section group of the notebook's.
15pub const BIN: &str = "OneNote_RecycleBin";
16
17/// What the bin's commands ask of the section it shows.
18pub 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.
31pub fn binned(path: &str) -> bool {
32 library::recycle_bin(&menus::folder(path))
33}
34
35impl 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.
165fn 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.
207pub 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)]
241mod 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) => self86 Target::Page(space) => self
88 .session87 .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
6use crate::{Library, State, UserEvent, platform, print};
7use accesskit::Role;
8use notebook::package;
9use std::{error::Error, sync::Arc};
10use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px};
11use winit::keyboard::NamedKey;
12
13/// What Save As saves.
14#[derive(Clone, Copy, PartialEq, Eq)]
15pub enum Scope {
16 Page,
17 Section,
18 Notebook,
19}
20
21const 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.
28fn 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.
43pub struct Dialog {
44 library: Arc<Library>,
45 section: Option<String>,
46 scope: Scope,
47 format: usize,
48}
49
50fn id() -> Id {
51 Id::ROOT.child("save-as")
52}
53
54impl 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
230fn 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.
237fn 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(
272fn sections(library: &Library) -> Vec<String> {272fn 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 folder279 .flat_map(|folder| &folder.sections)
280 .sections280 .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 stored339 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 when480 /// 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> = self487 let identity: Vec<usize> = self
495 .notebooks488 .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 }
521511
...@@ -544,7 +534,7 @@ impl State {...@@ -544,7 +534,7 @@ impl State {
544 let open = self534 let open = self
545 .session535 .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)]
57pub 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
64impl 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}
5273
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}
4747
48/// Names a notebook's or group's rows for folding them.
49pub 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.
54pub struct Tree<'a> {49pub 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}
7269
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}
8987
90impl<'a> Tree<'a> {88impl<'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}
139138
140/// The sidebar's header row, `header` tall and dragging the window when `drags`, then139/// 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 button140/// 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.
143fn sidebar(142fn 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 lifted514 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}
598610
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 = self751 let index = self
734 .notebooks752 .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 = self761 tree.lifted = self
742 .drag762 .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 }
802821
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.commands1066 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 providers1//! 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.
33
4use crate::platform::symbol;
4use canvas::spelling::{Dictionary, pick};5use canvas::spelling::{Dictionary, pick};
5use std::collections::HashMap;6use std::collections::HashMap;
6use std::ffi::{CStr, CString, c_char, c_int, c_void};7use 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}
100101
101/// The function `name` in `library`, of C signature `F`.
102unsafe fn symbol<F: Copy>(library: *mut c_void, name: &CStr) -> Option<F> {
103 let symbol = unsafe { libc::dlsym(library, name.as_ptr()) };
104 (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) })
105}
106
107unsafe extern "C" fn describe(102unsafe 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 }
9393
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 }
105105
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 }
115111
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 }
122118
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), and1//! 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 sRGB2//! 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.
46
5#[path = "surface.rs"]7#[path = "surface.rs"]
6mod webgpu;8mod webgpu;
79
8use draw::{GlTarget, Renderer, Target};10use draw::{Renderer, Target};
9use std::{error::Error, sync::Arc};11use std::{error::Error, sync::Arc};
10use windows_sys::Win32::{12use windows_sys::Win32::{
11 Foundation::HWND,13 Foundation::HWND,
...@@ -27,13 +29,14 @@ pub struct Surface {...@@ -27,13 +29,14 @@ pub struct Surface {
2729
28enum Backend {30enum 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}
3942
...@@ -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 }
163175
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 }
171183
...@@ -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.
79fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> {79fn 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 = library81 let mut sections = library
90 .background82 .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: worst115 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}
129119
120/// `sync` again; its error, which can't be cloned, as one of the same kind and message.
121fn 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
130fn changes(count: u64) -> String {130fn 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";
175fn update_note(update: &update::Status) -> Option<String> {175fn 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.
283fn 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}
277399
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 }
356475
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 section735 // 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<_> = facts738 let behind: Vec<_> = facts
535 .sections739 .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 }
673893
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 picked937 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 }
11121336
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}
347347
348/// The trailing row of a dialog: its buttons at the right.
349fn 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.
369fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) {349fn 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 }
176161
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 back3//! 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.
55
6use crate::library::{entry_name, folders};
6use crate::{Command, Library, Loaded, State, UserEvent};7use crate::{Command, Library, Loaded, State, UserEvent};
7use canvas::editor::CanvasEditor;8use canvas::editor::CanvasEditor;
8use notebook::{Replica, discover::Folder};9use 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}
288289
289/// Whether `page` holds nothing but its title, untitled.290/// Whether `page` holds nothing but its title, untitled.
290fn blank(page: &Page) -> bool {291pub(crate) fn blank(page: &Page) -> bool {
291 page.title.trim().is_empty()292 page.title.trim().is_empty()
292 && page293 && page
293 .objects294 .objects
...@@ -353,6 +354,46 @@ impl Site {...@@ -353,6 +354,46 @@ impl Site {
353 Ok(())354 Ok(())
354 }355 }
355356
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> = added489 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> = added612 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}
643654
644/// Every folder of `catalog`, the notebook's own first.
645fn 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.
656fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> {656fn 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> {
664664
665/// The section or group `identity` names: its folder and catalog path.665/// The section or group `identity` names: its folder and catalog path.
666fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> {666fn 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)> {
674674
675/// The identity of the section or group at catalog `path`.675/// The identity of the section or group at catalog `path`.
676fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> {676fn 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}
682682
683/// A section or group's name, as its catalog path ends.
684fn 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 be683/// 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.
691pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option<Action> {685pub 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
5use crate::{State, UserEvent, art, menus::SECTION_COLORS, platform};
6use accesskit::Role;
7use notebook::session::Notebook;
8use std::path::{Path, PathBuf};
9use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px};
10use winit::keyboard::NamedKey;
11
12const TITLE: &str = "Unpack Notebook";
13/// The most a package may unpack to.
14const LIMIT: u64 = 16 << 30;
15
16/// The dialog's fields while it is open.
17pub 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
25fn id() -> Id {
26 Id::ROOT.child("unpack")
27}
28
29fn name_field() -> Id {
30 id().child("name")
31}
32
33impl 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
7use crate::{Library, State, UserEvent, menus, recycle};
8use onestore::ExGuid;
9use 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.
16const TIME32: u64 = 315_532_800;
17
18/// What has been read, by notebook location.
19#[derive(Default, serde::Serialize, serde::Deserialize)]
20pub 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)]
34struct 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)]
43struct Section {
44 /// Time32 up to which changes to the section's pages are known.
45 read: u32,
46 unread: BTreeSet<ExGuid>,
47}
48
49fn hex(identity: [u8; 16]) -> String {
50 identity.iter().map(|byte| format!("{byte:02x}")).collect()
51}
52
53fn 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
60impl 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
139impl 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)]
372mod 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}
7474
75impl Version {75impl 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 {
120struct Build {120struct 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)]
131pub 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")]
140pub enum Kind {
141 Feature,
142 Fix,
143 #[serde(other)]
144 Other,
145}
146
147impl 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.
160pub 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}
124182
125#[derive(Clone, Debug, Deserialize)]183#[derive(Clone, Debug, Deserialize)]
...@@ -169,25 +227,35 @@ fn newer(...@@ -169,25 +227,35 @@ fn newer(
169}227}
170228
171/// `platform`'s archive in the build `build.json` describes, once its signature holds and229/// `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.
173fn archive(231fn 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 build245 let archive = build
187 .archives246 .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}
192260
193fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> {261fn 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}
311380
...@@ -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}
414487
415impl Updates {488impl 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 }
479553
...@@ -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}
550630
631/// `lead`, then what `changes` amount to and the first dozen of their titles under their
632/// kinds, as a dialog's detail.
633fn 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.
552pub fn show_build(version: &Version) {654pub 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 }
698800
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();
733848
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());
760877
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 }
782899
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 {
8691082
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 !fetched1090 !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);
51static COMPOSED: AtomicBool = AtomicBool::new(false);51static 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.
53static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000);53static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000);
54/// The window's smallest client size in points, as `f32` bits.
55static 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 it56/// 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}
261263
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.
267pub 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.
284fn 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.
292fn 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
262fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool {304fn 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}
654704
655/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons705/// 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
658pub 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() {711pub 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 ..theme719 ..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}
671735
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.
738fn 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.
673pub fn menu(_: Theme) -> Option<ui::Menu> {758pub fn menu(_: Theme) -> Option<ui::Menu> {
674 None759 None
...@@ -1402,7 +1487,10 @@ pub fn edit_date(...@@ -1402,7 +1487,10 @@ pub fn edit_date(
1402pub fn pick_notebook(title: &str) -> Option<PathBuf> {1487pub 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.
257fn 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`.
265fn 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.
278fn 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
292fn 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]
299fn 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]
321fn 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]
335fn 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.
317fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {317fn 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.
323fn 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 f32326 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}
346345
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}
326326
327impl 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)]
329pub struct Spec<'a> {338pub 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 }
798802
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 }
804811
...@@ -1196,7 +1203,7 @@ impl Ui {...@@ -1196,7 +1203,7 @@ impl Ui {
11961203
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 }
12011208
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 }
12491256
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 the1257 /// 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 value1263 value
1258 }1264 }
12591265
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.texts1302 self.texts
1296 .label(text, self.theme.font_size, false, None, self.frame)1303 .label(text, self.theme.font_size, self.frame)
1297 .size1304 .size
1298 }1305 }
12991306
...@@ -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 opacity1328 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 layers1965 layers
1962 }1966 }
19631967
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}
19881972
crates/ui/src/popup.rs+55-96
...@@ -86,6 +86,13 @@ impl Item<'_> {...@@ -86,6 +86,13 @@ impl Item<'_> {
86 }86 }
87}87}
8888
89/// Whether `items`' rows leave room for an icon or a check.
90fn 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 a96/// 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 item97/// 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 + shortcut126 3.0 * ICON_GAP + shortcut
127 } else {127 } else {
128 0.0128 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 GUTTER131 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 None811 None
839}812}
840813
...@@ -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}
847820
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 the822/// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose.
850/// columns and rows chosen.823fn pick_cell(
851pub 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.
865pub 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 None963 None
976}964}
977965
...@@ -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 None1078 None
1118}1079}
11191080
...@@ -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: items1452 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 the35/// 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 it36/// 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)]
39pub fn section_tabs(39pub 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<_> = tabs53 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}
308312
313/// `color` as a control that does not apply now shows it.
314fn 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.
311pub fn unavailable(320pub 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`.
606fn point_to(ui: &mut Ui, text: &str, index: usize) {606fn 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<_> = names2818 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}
2525
26impl Texts {26impl 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 }
3831
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; the23 /// 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.pressed162 if signal.pressed
163 && let Some(pointer) = pointer163 && 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 * span170 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 placeholder484 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.
3version = 43version = 4
44
5[[package]]
6name = "adler2"
7version = "2.0.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
11[[package]]
12name = "aead"
13version = "0.6.1"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
16dependencies = [
17 "crypto-common",
18 "inout",
19]
20
5[[package]]21[[package]]
6name = "aes"22name = "aes"
7version = "0.9.3"23version = "0.9.3"
...@@ -14,6 +30,20 @@ dependencies = [...@@ -14,6 +30,20 @@ dependencies = [
14 "zeroize",30 "zeroize",
15]31]
1632
33[[package]]
34name = "aes-gcm"
35version = "0.11.1"
36source = "registry+https://github.com/rust-lang/crates.io-index"
37checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
38dependencies = [
39 "aead",
40 "aes",
41 "cipher",
42 "ctr",
43 "ctutils",
44 "ghash",
45]
46
17[[package]]47[[package]]
18name = "arbitrary"48name = "arbitrary"
19version = "1.4.2"49version = "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]
79109
110[[package]]
111name = "byteorder"
112version = "1.5.0"
113source = "registry+https://github.com/rust-lang/crates.io-index"
114checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
115
116[[package]]
117name = "cab"
118version = "0.6.0"
119source = "registry+https://github.com/rust-lang/crates.io-index"
120checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
121dependencies = [
122 "byteorder",
123 "flate2",
124 "lzxd",
125 "time",
126]
127
80[[package]]128[[package]]
81name = "canvas"129name = "canvas"
82version = "0.1.0"130version = "0.1.0"
...@@ -135,6 +183,12 @@ dependencies = [...@@ -135,6 +183,12 @@ dependencies = [
135 "zeroize",183 "zeroize",
136]184]
137185
186[[package]]
187name = "cmov"
188version = "0.5.4"
189source = "registry+https://github.com/rust-lang/crates.io-index"
190checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
191
138[[package]]192[[package]]
139name = "const-oid"193name = "const-oid"
140version = "0.10.2"194version = "0.10.2"
...@@ -156,15 +210,50 @@ dependencies = [...@@ -156,15 +210,50 @@ dependencies = [
156 "libc",210 "libc",
157]211]
158212
213[[package]]
214name = "crc32fast"
215version = "1.5.2"
216source = "registry+https://github.com/rust-lang/crates.io-index"
217checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
218dependencies = [
219 "cfg-if",
220]
221
159[[package]]222[[package]]
160name = "crypto-common"223name = "crypto-common"
161version = "0.2.2"224version = "0.2.2"
162source = "registry+https://github.com/rust-lang/crates.io-index"225source = "registry+https://github.com/rust-lang/crates.io-index"
163checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"226checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
164dependencies = [227dependencies = [
228 "getrandom",
165 "hybrid-array",229 "hybrid-array",
230 "rand_core",
166]231]
167232
233[[package]]
234name = "ctr"
235version = "0.10.1"
236source = "registry+https://github.com/rust-lang/crates.io-index"
237checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
238dependencies = [
239 "cipher",
240]
241
242[[package]]
243name = "ctutils"
244version = "0.4.2"
245source = "registry+https://github.com/rust-lang/crates.io-index"
246checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
247dependencies = [
248 "cmov",
249]
250
251[[package]]
252name = "deranged"
253version = "0.5.8"
254source = "registry+https://github.com/rust-lang/crates.io-index"
255checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
256
168[[package]]257[[package]]
169name = "digest"258name = "digest"
170version = "0.11.3"259version = "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]
179269
...@@ -232,12 +322,32 @@ version = "2.5.0"...@@ -232,12 +322,32 @@ version = "2.5.0"
232source = "registry+https://github.com/rust-lang/crates.io-index"322source = "registry+https://github.com/rust-lang/crates.io-index"
233checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"323checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
234324
325[[package]]
326name = "file-guard"
327version = "0.2.0"
328source = "registry+https://github.com/rust-lang/crates.io-index"
329checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c"
330dependencies = [
331 "libc",
332 "winapi",
333]
334
235[[package]]335[[package]]
236name = "find-msvc-tools"336name = "find-msvc-tools"
237version = "0.1.12"337version = "0.1.12"
238source = "registry+https://github.com/rust-lang/crates.io-index"338source = "registry+https://github.com/rust-lang/crates.io-index"
239checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"339checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
240340
341[[package]]
342name = "flate2"
343version = "1.1.10"
344source = "registry+https://github.com/rust-lang/crates.io-index"
345checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
346dependencies = [
347 "crc32fast",
348 "miniz_oxide",
349]
350
241[[package]]351[[package]]
242name = "foldhash"352name = "foldhash"
243version = "0.2.0"353version = "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]]
400name = "ghash"
401version = "0.6.0"
402source = "registry+https://github.com/rust-lang/crates.io-index"
403checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
404dependencies = [
405 "polyval",
286]406]
287407
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]
326446
447[[package]]
448name = "hmac"
449version = "0.13.0"
450source = "registry+https://github.com/rust-lang/crates.io-index"
451checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
452dependencies = [
453 "digest",
454]
455
327[[package]]456[[package]]
328name = "hybrid-array"457name = "hybrid-array"
329version = "0.4.14"458version = "0.4.14"
...@@ -556,6 +685,12 @@ version = "0.8.3"...@@ -556,6 +685,12 @@ version = "0.8.3"
556source = "registry+https://github.com/rust-lang/crates.io-index"685source = "registry+https://github.com/rust-lang/crates.io-index"
557checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"686checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
558687
688[[package]]
689name = "lzxd"
690version = "0.2.7"
691source = "registry+https://github.com/rust-lang/crates.io-index"
692checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168"
693
559[[package]]694[[package]]
560name = "md5"695name = "md5"
561version = "0.8.1"696version = "0.8.1"
...@@ -577,6 +712,16 @@ dependencies = [...@@ -577,6 +712,16 @@ dependencies = [
577 "libc",712 "libc",
578]713]
579714
715[[package]]
716name = "miniz_oxide"
717version = "0.9.1"
718source = "registry+https://github.com/rust-lang/crates.io-index"
719checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
720dependencies = [
721 "adler2",
722 "simd-adler32",
723]
724
580[[package]]725[[package]]
581name = "nix"726name = "nix"
582version = "0.31.3"727version = "0.31.3"
...@@ -593,6 +738,12 @@ dependencies = [...@@ -593,6 +738,12 @@ dependencies = [
593name = "notebook"738name = "notebook"
594version = "0.1.0"739version = "0.1.0"
595dependencies = [740dependencies = [
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]
604757
758[[package]]
759name = "num-conv"
760version = "0.2.2"
761source = "registry+https://github.com/rust-lang/crates.io-index"
762checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
763
605[[package]]764[[package]]
606name = "objc2"765name = "objc2"
607version = "0.6.4"766version = "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"
735source = "registry+https://github.com/rust-lang/crates.io-index"895source = "registry+https://github.com/rust-lang/crates.io-index"
736checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"896checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
737897
898[[package]]
899name = "polyval"
900version = "0.7.3"
901source = "registry+https://github.com/rust-lang/crates.io-index"
902checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
903dependencies = [
904 "cpubits",
905 "cpufeatures",
906 "universal-hash",
907]
908
738[[package]]909[[package]]
739name = "potential_utf"910name = "potential_utf"
740version = "0.1.6"911version = "0.1.6"
...@@ -746,6 +917,12 @@ dependencies = [...@@ -746,6 +917,12 @@ dependencies = [
746 "zerovec",917 "zerovec",
747]918]
748919
920[[package]]
921name = "powerfmt"
922version = "0.2.0"
923source = "registry+https://github.com/rust-lang/crates.io-index"
924checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
925
749[[package]]926[[package]]
750name = "proc-macro2"927name = "proc-macro2"
751version = "1.0.107"928version = "1.0.107"
...@@ -770,6 +947,12 @@ version = "6.0.0"...@@ -770,6 +947,12 @@ version = "6.0.0"
770source = "registry+https://github.com/rust-lang/crates.io-index"947source = "registry+https://github.com/rust-lang/crates.io-index"
771checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"948checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
772949
950[[package]]
951name = "rand_core"
952version = "0.10.1"
953source = "registry+https://github.com/rust-lang/crates.io-index"
954checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
955
773[[package]]956[[package]]
774name = "read-fonts"957name = "read-fonts"
775version = "0.41.0"958version = "0.41.0"
...@@ -905,6 +1088,12 @@ version = "2.0.1"...@@ -905,6 +1088,12 @@ version = "2.0.1"
905source = "registry+https://github.com/rust-lang/crates.io-index"1088source = "registry+https://github.com/rust-lang/crates.io-index"
906checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"1089checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
9071090
1091[[package]]
1092name = "simd-adler32"
1093version = "0.3.10"
1094source = "registry+https://github.com/rust-lang/crates.io-index"
1095checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
1096
908[[package]]1097[[package]]
909name = "skrifa"1098name = "skrifa"
910version = "0.44.0"1099version = "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]
10131202
1203[[package]]
1204name = "time"
1205version = "0.3.55"
1206source = "registry+https://github.com/rust-lang/crates.io-index"
1207checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
1208dependencies = [
1209 "deranged",
1210 "num-conv",
1211 "powerfmt",
1212 "serde_core",
1213 "time-core",
1214]
1215
1216[[package]]
1217name = "time-core"
1218version = "0.1.9"
1219source = "registry+https://github.com/rust-lang/crates.io-index"
1220checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
1221
1014[[package]]1222[[package]]
1015name = "tinystr"1223name = "tinystr"
1016version = "0.8.4"1224version = "0.8.4"
...@@ -1034,6 +1242,16 @@ version = "1.0.24"...@@ -1034,6 +1242,16 @@ version = "1.0.24"
1034source = "registry+https://github.com/rust-lang/crates.io-index"1242source = "registry+https://github.com/rust-lang/crates.io-index"
1035checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"1243checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
10361244
1245[[package]]
1246name = "universal-hash"
1247version = "0.6.1"
1248source = "registry+https://github.com/rust-lang/crates.io-index"
1249checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
1250dependencies = [
1251 "crypto-common",
1252 "ctutils",
1253]
1254
1037[[package]]1255[[package]]
1038name = "utf16_iter"1256name = "utf16_iter"
1039version = "1.0.5"1257version = "1.0.5"
...@@ -1097,6 +1315,28 @@ dependencies = [...@@ -1097,6 +1315,28 @@ dependencies = [
1097 "unicode-ident",1315 "unicode-ident",
1098]1316]
10991317
1318[[package]]
1319name = "winapi"
1320version = "0.3.9"
1321source = "registry+https://github.com/rust-lang/crates.io-index"
1322checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
1323dependencies = [
1324 "winapi-i686-pc-windows-gnu",
1325 "winapi-x86_64-pc-windows-gnu",
1326]
1327
1328[[package]]
1329name = "winapi-i686-pc-windows-gnu"
1330version = "0.4.0"
1331source = "registry+https://github.com/rust-lang/crates.io-index"
1332checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1333
1334[[package]]
1335name = "winapi-x86_64-pc-windows-gnu"
1336version = "0.4.0"
1337source = "registry+https://github.com/rust-lang/crates.io-index"
1338checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1339
1100[[package]]1340[[package]]
1101name = "windows"1341name = "windows"
1102version = "0.62.2"1342version = "0.62.2"
fuzz/Cargo.toml+1-1
...@@ -9,7 +9,7 @@ cargo-fuzz = true...@@ -9,7 +9,7 @@ cargo-fuzz = true
99
10[dependencies]10[dependencies]
11libfuzzer-sys = "0.4"11libfuzzer-sys = "0.4"
12onestore = { path = "../crates/onestore", features = ["protected"] }12onestore = { path = "../crates/onestore" }
13notebook = { path = "../crates/notebook" }13notebook = { path = "../crates/notebook" }
14canvas = { path = "../crates/canvas" }14canvas = { path = "../crates/canvas" }
15draw = { path = "../crates/draw", default-features = false }15draw = { 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]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
33
4#[path = "../../crates/onestore/tests/support/ops.rs"]
5mod ops;
6
4#[path = "../../crates/notebook/tests/support/model_ops.rs"]7#[path = "../../crates/notebook/tests/support/model_ops.rs"]
5mod model_ops;8mod 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]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
33
4#[path = "../../crates/onestore/tests/support/ops.rs"]
5mod ops;
6
4#[path = "../../crates/onestore/tests/support/disk.rs"]7#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;8mod 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]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3use onestore::{3use onestore::{
4 Reference, RevisionIndex, Store,4 Arena, Reference, RevisionIndex, Section, Store,
5 protected::{Limits, UnlockedSection},5 protected::{Key, Limits, UnlockedSection},
6};6};
7use std::{ops::Range, sync::LazyLock};7use std::{ops::Range, sync::LazyLock};
88
9static SOURCES: LazyLock<Vec<(Vec<u8>, String, Vec<Range<usize>>)>> = LazyLock::new(|| {9type Source = (Vec<u8>, String, Vec<Range<usize>>, Key);
10
11static 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});
2731
...@@ -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 same2//! 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 takes4//! 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.
6use libfuzzer_sys::fuzz_target;7use libfuzzer_sys::fuzz_target;
7use onestore::{8use 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};
14use std::sync::LazyLock;
1415
15const SOURCE: &[u8] =16const SOURCE: &[u8] =
16 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");17 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");
17const PASSWORD: &str = "fictitious-only";18static KEYS: LazyLock<[Key; 2]> = LazyLock::new(|| {
19 [
20 Key::open(SOURCE, "fictitious-only").unwrap(),
21 Key::new("another fictitious password").unwrap(),
22 ]
23});
1824
19/// The first page, and the texts whose empty final run keeps an insertion style.25/// The first page.
20fn page(bytes: &[u8]) -> (ExGuid, Page, Vec<ExGuid>) {26fn 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}
3832
39fn texts(page: &Page) -> Vec<String> {33fn 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> {
5044
51fuzz_target!(|data: &[u8]| {45fuzz_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<_> = expected61 let candidates: Vec<_> = expected
59 .objects62 .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
2exec 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.
5set -eu
6here=$(cd "$(dirname "$0")" && pwd)
7case ${1:-} in
8x86_64 | aarch64) ;;
9*)
10 echo "usage: $0 x86_64|aarch64 COMMAND ARGS..." >&2
11 exit 2
12 ;;
13esac
14arch=$1 command=$2
15shift 2
16command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; }
17triple=$arch-unknown-linux-gnu
18rustup target add "$triple" >/dev/null
19variable=$(echo "$triple" | tr - _)
20env "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.
4for arg do
5 shift
6 case $arg in --target=* | -Wl,--no-undefined-version | -Wl,-O1) ;; *) set -- "$@" "$arg" ;; esac
7done
8exec 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]
440441
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() {
11fn run() {11fn 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```text20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}
222026-09-29.r10/222026-09-29.r10/
23 build.json version, commit, and per platform: file, size, sha256, signature23 build.json version, commit, changes, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex24 build.json.sig ed25519 signature of build.json, hex
25 Snowbound-2026-09-29-r10-macos-aarch64.zip25 Snowbound-2026-09-29-r10-macos-aarch64.zip
26 Snowbound-2026-09-29-r10-macos-x86_64.zip26 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
38was pointed to, and a forged pointer can only name another signed build, which38was pointed to, and a forged pointer can only name another signed build, which
39the app ignores unless it is newer than itself.39the app ignores unless it is newer than itself.
4040
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
45bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`,
46where `version` is the version of the commit that made it. An updating app sums
47the entries newer than itself, so releases it skipped count too, and says "3
48features, 5 bug fixes, and 2 other changes" with the titles beneath. The list
49is in `build.json` because that is signed; `latest.json` stays a bare pointer.
50
51A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug
52fix, anything else (`docs`, `chore`, no prefix) another change. It counts once,
53titled by its subject without the prefix, unless its body has a top-level
54bulleted list (`- ` or `* ` at the start of a line, wrapped lines indented),
55which counts each item instead, all of the prefix's kind. So a fix is best its
56own small `fix:` commit, and a batch commit should bullet what it brings.
57
58Entries run about 190 bytes, so `build.json` stays under the 1 MiB that apps,
59old ones included, read it within until some 5,000 entries; apps ignore fields
60they don't know, and builds without `changes` read as listing none.
61
41## Signing62## Signing
4263
43Every `build.json` and archive is signed with the ed25519 release key in64Every `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```
91112
92The script refuses to run unless the working copy matches `main` (a dry run113The script refuses to run unless the working copy matches `main` (a dry run
93only warns), runs Clippy (workspace, and `snowbound` without default features)114only warns), gates the commit with `tools/ci.py` (see `tools/TESTING.md`),
94and the workspace tests, builds each platform with115builds 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) and117`crates/snowbound/linux/package.sh` (taking only its executables) and
97`platform/windows/cargo.sh`, then checks118`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
137its own version. It stages the update beside the install, so the swap is a rename:158its own version. It stages the update beside the install, so the swap is a rename:
138the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the159the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
139executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on160executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
140Windows). The sync status icon gets a dot, and its popup offers161Windows). The sync status icon gets a dot, and its popup says what the update
141Build Folder and Restart to Update.162changes, unfolding to the titles, and offers Build Folder and Restart to
163Update; Check for Updates… lists them in its dialog.
142164
143Restart to Update quits the app and starts the old executable with165Restart 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 (or166`--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 lanes1# Library regression lanes
22
3## The CI gate
4
5`python3 tools/ci.py` gates `main` (or `--rev REV`, or `--working-copy` for this
6checkout's `@`) in the jj workspace `../snowbound-ci`: it points that
7workspace's own commit, a child of `main`, at the revision's files, so other
8checkouts' edits in progress never reach the result, and a working copy is
9frozen 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
11another, 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
25python3 tools/ci.py # every lane, on main
26python3 tools/ci.py --working-copy --changed # the lanes and test packages @'s changes from main reach
27python3 tools/ci.py --rev xyz --lanes test windows # `windows` names both windows-* lanes
28```
29
30Lanes run four at a time (`--jobs`), each under its own time limit
31(`--timeout MINUTES` overrides them all). The table it prints names each
32failure's first errors with their files and lines, to tell whose edit broke
33it; `../snowbound-ci/target/ci/runs/TIME/` keeps every lane's log and
34`summary.json` (status, seconds, errors with files, each test executable's
35time), for the last 20 runs. After a run over `--budget` (80 GB), it deletes
36the 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
40publishes.
41
42The workspace is made on first use; to drop it, `jj workspace forget ci` and
43delete `../snowbound-ci`.
44
3## Public fixtures45## Public fixtures
446
5From a checkout with Rust and [uv](https://docs.astral.sh/uv/), which provides47From 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
5252
53Launched 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`).53Launched 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`).
5454
55As 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
55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. 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.57The `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.
5658
57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `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"}`.59A 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]
44TEAM = '9R7DPNW28H'44TEAM = '9R7DPNW28H'
45BUNDLE_ID = 'net.paperclover.snowbound'45BUNDLE_ID = 'net.paperclover.snowbound'
46CONTAINER = 'iCloud.net.paperclover.snowbound'46CONTAINER = '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.
48ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'),48ONENOTE_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')]
5051
5152
52def developer_id():53def 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
3every platform's build, checked out in the jj workspace ../snowbound-ci so that edits in
4progress elsewhere never reach it. See tools/TESTING.md."""
5import argparse
6from concurrent.futures import ThreadPoolExecutor
7from datetime import datetime
8import fcntl
9import json
10import os
11from pathlib import Path
12import re
13import shutil
14import signal
15import subprocess
16import sys
17import threading
18import time
19
20ROOT = Path(__file__).resolve().parents[1]
21CI = ROOT.parent / 'snowbound-ci'
22TARGET = CI / 'target'
23RUNS = TARGET / 'ci'
24# Changes here rebuild every package.
25EVERYTHING = ('Cargo.toml', 'Cargo.lock', 'vendor/', 'rust-toolchain', '.cargo/')
26JSON = '--message-format=json'
27
28
29def cargo(*args):
30 return ['cargo', args[0], '--locked', JSON, *args[1:]]
31
32
33def cross(script, arch, *args):
34 return ['sh', script, arch, args[0], '--locked', JSON, *args[1:]]
35
36
37def 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
95def jj(*args, cwd=ROOT):
96 return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
97
98
99def 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
112class 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
153def 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
163def status(code):
164 return 'passed' if code == 0 else 'timeout' if code is None else 'failed'
165
166
167def 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
176def 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
213def relative(path):
214 return path.removeprefix(f'{CI}/')
215
216
217def 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
260def 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
274def 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
286def 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
292def 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
322def disk_usage():
323 return int(subprocess.check_output(['du', '-sk', TARGET]).split()[0]) * 1024
324
325
326def 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
343def 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
447if __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."""
2import os2import os
3from pathlib import Path3from pathlib import Path
4import shutil
5import subprocess4import subprocess
65
76
8def _exporter():7def _exporter():
9 """The document exporter built with protected sections, copied aside because a workspace8 """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.parent11 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
1915
2016
21EXPORTER = _exporter()17EXPORTER = _exporter()
tools/native/protected.ps1 created+42
...@@ -0,0 +1,42 @@
1param(
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.
9Set-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)
15if (-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"]'))
24if ($pages.Count -eq 0) { throw 'The section shows no pages: it is still locked.' }
25$xml = $null
26$children = $null
27foreach ($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}
34if (-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)
41Start-Sleep -Seconds 15
42Write-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
2through OneNote's Protected Section dialog with its password, type into one through COM as an
3add-in may, and read every page back with the notebook OneNote leaves.
4
5Usage: native_protected.py NOTEBOOK_DIR PASSWORDS_JSON OUTPUT_DIR [EDITED_SECTION]
6"""
7import base64
8import json
9import sys
10from pathlib import Path
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13import native_runner as runner
14
15windows = runner.windows
16ROOT = Path(__file__).resolve().parent.parent
17REMOTE = 'C:\\one-tests\\runs\\capture'
18
19
20def unlock_script(password):
21 return '''WinActivate("ahk_class Framework::CFrame")
22WinWaitActive("ahk_class Framework::CFrame", , 30)
23Sleep(2000)
24Send("{Enter}")
25if !WinWait("Protected Section", , 15)
26 throw Error("No Protected Section dialog")
27Sleep(500)
28SendText("%s")
29Send("{Enter}")
30Sleep(4000)
31FileAppend(WinGetTitle("A"), "*")
32''' % password.replace('"', '""')
33
34
35def 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
57if __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
6import json6import json
7import os7import os
8from pathlib import Path8from pathlib import Path
9import re
9import shutil10import shutil
10import subprocess11import subprocess
11import sys12import 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.
23WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}24WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}
24CHECKS = [
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 account25# 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.
31IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'27IDENTITY = '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}.
33NOTARY = Path('~/.config/snowbound/notary.json').expanduser()29NOTARY = Path('~/.config/snowbound/notary.json').expanduser()
30# The first published build's commit: no client runs anything older, so changes start after it.
31FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781'
32KINDS = {'feat': 'feature', 'fix': 'fix'}
3433
3534
36def derive(release, commits):35def 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)
8079
8180
82def commit_times(revset):81def 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
92def 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
103def version_of(commits, commit):
104 return derive(commits[commit][1], [commits[each][1] for each in ancestors(commits, commit)])
105
106
107def 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
128def 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])]
85135
86136
87def sign(files):137def sign(files):
...@@ -164,7 +214,8 @@ def main():...@@ -164,7 +214,8 @@ def main():
164214
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)
169220
170 published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED221 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
2unpacks and opens, the notebook Snowbound unpacks cold-opens in OneNote as written, and
3OneNote's own LZX package lists the files Snowbound unpacks."""
4
5import pathlib
6import struct
7import unittest
8import xml.etree.ElementTree as ElementTree
9import zlib
10
11ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "notebook-package"
12ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}"
13
14
15def 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
54def 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
66EXPECTED = [
67 ("Section", "New Section 1", [("Page", "First", [])]),
68 ("SectionGroup", "Group", [("Section", "Inner", [("Page", "Inside", [])])]),
69]
70
71
72class 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
112if __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
2Snowbound protected, edited, gave another password, unprotected and merged, and Snowbound
3reads what OneNote wrote, protecting, changing and removing passwords and typing into them
4(`corpus/protected-sections`)."""
5import json
6from pathlib import Path
7import re
8import subprocess
9from tempfile import TemporaryDirectory
10import unittest
11import xml.etree.ElementTree as ET
12
13from document_model import EXPORTER
14
15ROOT = Path(__file__).resolve().parent.parent
16FIXTURE = ROOT / 'corpus/protected-sections'
17ONE = '{http://schemas.microsoft.com/office/onenote/2010/onenote}'
18
19
20def 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
29def plain(text):
30 """A native `T` element's text without its HTML spans."""
31 return re.sub(r'<[^>]+>', '', text).replace('\n', ' ').strip()
32
33
34def squeezed(text):
35 return re.sub(r'\s', '', text)
36
37
38def 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
43class 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
130if __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
2the notebook after pages and a section left it and the rest was emptied, without repairing
3either."""
4
5import pathlib
6import unittest
7import xml.etree.ElementTree as ElementTree
8
9ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "recycle-bin-view"
10ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}"
11
12
13def 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
28class 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
67if __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')
2424
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 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/rtl-page'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15class 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
30if __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 them379 # 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.0383 origin = source_page['kind']['margin_origin_' + axis]
384 extent[1] += source['layout']['y'] or 0.0384 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.4398 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})