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 = [
563563 "syn 3.0.5",
564564]
565565
566[[package]]
567name = "byteorder"
568version = "1.5.0"
569source = "registry+https://github.com/rust-lang/crates.io-index"
570checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
571
566572[[package]]
567573name = "byteorder-lite"
568574version = "0.1.0"
......@@ -575,6 +581,18 @@ version = "1.12.1"
575581source = "registry+https://github.com/rust-lang/crates.io-index"
576582checksum = "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
578596[[package]]
579597name = "calloop"
580598version = "0.13.0"
......@@ -900,6 +918,12 @@ dependencies = [
900918 "hybrid-array",
901919]
902920
921[[package]]
922name = "deranged"
923version = "0.5.8"
924source = "registry+https://github.com/rust-lang/crates.io-index"
925checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
926
903927[[package]]
904928name = "digest"
905929version = "0.11.3"
......@@ -987,6 +1011,7 @@ dependencies = [
9871011 "subsetter",
9881012 "swash",
9891013 "wgpu",
1014 "windows",
9901015]
9911016
9921017[[package]]
......@@ -1849,6 +1874,12 @@ dependencies = [
18491874 "twox-hash",
18501875]
18511876
1877[[package]]
1878name = "lzxd"
1879version = "0.2.7"
1880source = "registry+https://github.com/rust-lang/crates.io-index"
1881checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168"
1882
18521883[[package]]
18531884name = "md-5"
18541885version = "0.11.0"
......@@ -1946,6 +1977,7 @@ dependencies = [
19461977 "serde_json",
19471978 "tempfile",
19481979 "wgpu",
1980 "zeroize",
19491981]
19501982
19511983[[package]]
......@@ -2042,6 +2074,11 @@ dependencies = [
20422074name = "notebook"
20432075version = "0.1.0"
20442076dependencies = [
2077 "aes-gcm",
2078 "base64 0.22.1",
2079 "cab",
2080 "getrandom 0.4.3",
2081 "hmac",
20452082 "libc",
20462083 "nix",
20472084 "onestore",
......@@ -2067,6 +2104,12 @@ dependencies = [
20672104 "unicode-segmentation",
20682105]
20692106
2107[[package]]
2108name = "num-conv"
2109version = "0.2.2"
2110source = "registry+https://github.com/rust-lang/crates.io-index"
2111checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2112
20702113[[package]]
20712114name = "num-traits"
20722115version = "0.2.19"
......@@ -2747,6 +2790,12 @@ dependencies = [
27472790 "zerovec",
27482791]
27492792
2793[[package]]
2794name = "powerfmt"
2795version = "0.2.0"
2796source = "registry+https://github.com/rust-lang/crates.io-index"
2797checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2798
27502799[[package]]
27512800name = "presser"
27522801version = "0.3.1"
......@@ -3392,6 +3441,7 @@ dependencies = [
33923441 "windows-sys 0.61.2",
33933442 "winit",
33943443 "zbus",
3444 "zeroize",
33953445]
33963446
33973447[[package]]
......@@ -3591,6 +3641,25 @@ dependencies = [
35913641 "zune-jpeg",
35923642]
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
35943663[[package]]
35953664name = "tiny-skia"
35963665version = "0.11.4"
apps/ios/Snowbound.xcodeproj/project.pbxproj-4
......@@ -7,14 +7,12 @@
77 objects = {
88
99/* Begin PBXBuildFile section */
10 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; };
1110 5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; };
1211 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; };
1312/* End PBXBuildFile section */
1413
1514/* Begin PBXFileReference section */
1615 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; };
1816 5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; };
1917 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2018 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };
......@@ -45,7 +43,6 @@
4543 isa = PBXGroup;
4644 children = (
4745 5B0000000000000000000300 /* Snowbound */,
48 5B0000000000000000000201 /* Sample */,
4946 5B0000000000000000000226 /* Snowbound Guide */,
5047 5B0000000000000000000205 /* Snowbound-Tahoe.icon */,
5148 5B0000000000000000000202 /* Info.plist */,
......@@ -126,7 +123,6 @@
126123 isa = PBXResourcesBuildPhase;
127124 buildActionMask = 2147483647;
128125 files = (
129 5B0000000000000000000101 /* Sample in Resources */,
130126 5B0000000000000000000126 /* Snowbound Guide in Resources */,
131127 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */,
132128 );
apps/ios/Snowbound/App.swift+16-5
......@@ -150,6 +150,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
150150 private let notebooks = NotebooksViewController()
151151 private let pages = PagesViewController()
152152 private var showingPage = false
153 /// A first launch opened a page by itself.
154 private var landed = false
153155 private var background = UIBackgroundTaskIdentifier.invalid
154156
155157 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
......@@ -203,8 +205,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
203205 }
204206 return
205207 }
206 guard let place = Place.saved, place.notebook == notebook.id,
207 let tab = notebook.tabs.first(where: { $0.path == place.section })
208 guard let place = Place.saved else {
209 // The first notebook found opens at its first page, as Notes opens on a note.
210 guard Prototype.welcome, !landed, let tab = notebook.tabs.first(where: \.readable) else { return }
211 landed = true
212 return open(tab, of: notebook) { [weak self] section in
213 if let page = section.rows.first { self?.show(section: section, page: page.id) }
214 }
215 }
216 guard place.notebook == notebook.id, let tab = notebook.tabs.first(where: { $0.path == place.section })
208217 else { return }
209218 open(tab, of: notebook, page: place.page)
210219 }
......@@ -213,7 +222,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
213222 _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil,
214223 then: ((Section) -> Void)? = nil
215224 ) {
216 guard Author.name != nil else {
225 // The welcome prototype asks at the first edit instead.
226 guard Author.name != nil || Prototype.welcome else {
217227 return Author.ask(from: split) { [weak self] in
218228 self?.open(tab, of: notebook, page: page, reveal: reveal, then: then)
219229 }
......@@ -223,7 +233,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
223233 Section.open(tab, of: notebook) { [weak self] section, problem in
224234 guard let self else { return }
225235 guard let section else {
226 pages.failed(tab, problem)
236 pages.failed(problem)
227237 return
228238 }
229239 pages.load(section)
......@@ -300,7 +310,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
300310 /// Composition ends and every edit is stored and published while the system allows.
301311 func sceneDidEnterBackground(_ scene: UIScene) {
302312 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() }
304315 let sections = Section.all
305316 guard !sections.isEmpty, background == .invalid else { return }
306317 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
8181 private var origin = CGPoint.zero
8282 private var syncing = false
8383 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()
8489 /// How far right of the content's corner a page opens.
8590 private var left: CGFloat = 0
8691 /// The zoom a double tap returns to.
......@@ -230,7 +235,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
230235 paper()
231236 sync()
232237 let skip = margin
233 resting = openingZoom()
238 resting = openingZoom(skipping: skip)
234239 zoomScale = resting
235240 left = skip * resting
236241 home()
......@@ -301,12 +306,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
301306 dirty = true
302307 }
303308
304 /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's
305 /// 11 pt body text shows at 17 points.
306 private func openingZoom() -> CGFloat {
307 guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 }
308 // A page opens readable however far the last one was pinched.
309 return min(2, max(0.75, zoom))
309 /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's
310 /// 11 pt body text shows at 17 points; less where that fits the page right of `skip`
311 /// across the screen.
312 private func openingZoom(skipping skip: CGFloat) -> CGFloat {
313 let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1
314 let fit = bounds.inset(by: safeAreaInsets).width / max(1, content.bounds.width - skip)
315 // A page opens readable however far the last one was pinched or however wide it is.
316 return max(0.75, min(2, pinched, fit))
310317 }
311318
312319 /// 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
329336 if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
330337 }
331338
332 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true }
333 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true }
339 /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the
340 /// finger more than 40° off it lets go, and the page catches up over the next 48 points.
341 private struct Drag {
342 static let snap = tan(CGFloat.pi / 6)
343 static let release = tan(CGFloat.pi * 2 / 9)
344 static let stretch: CGFloat = 24
345 static let catchUp: CGFloat = 48
346
347 /// The axis the lock holds still.
348 let cross: WritableKeyPath<CGPoint, CGFloat>
349 var locked = true
350 /// The offset last shown, from which the scroll view's next step is measured.
351 var offset: CGPoint
352 /// The translation where the stretch of the gesture being judged began.
353 var stretch: CGPoint
354 /// How far the held axis trails the finger.
355 var lag: CGFloat = 0
356 }
357
358 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
359 scrolled = true
360 let moved = panGestureRecognizer.translation(in: self)
361 let cross: WritableKeyPath<CGPoint, CGFloat>? =
362 abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil
363 drag = cross.map { Drag(cross: $0, offset: contentOffset, stretch: moved) }
364 }
365
366 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
367 scrolled = true
368 drag = nil
369 pinchStart = spread.map { (zoomScale, $0) }
370 inDetent = abs(log(zoomScale)) < Self.detent
371 detentTick.prepare()
372 }
373
334374 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.
336416 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 }
337433 transform()
338434 // Scrolling carries the caret and handles along; zooming moves them within the page.
339435 display.setNeedsSelectionUpdate()
340436 }
341437
342438 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
439 pinchStart = nil
343440 if tapZooming {
344441 tapZooming = false
345442 } else {
346 resting = scale
347 UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey)
443 // A pinch let go within the detent settles on 100%.
444 resting = abs(log(scale)) < Self.detent ? 1 : scale
445 if resting != scale { setZoomScale(resting, animated: true) }
446 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
348447 }
349448 }
350449
......@@ -694,8 +793,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
694793 edit(external: true) { sb_delete_backward(handle) }
695794 }
696795
697 /// Runs a change to the page, telling the system when it did not ask for it.
698 private func edit(external: Bool = false, _ change: () -> Bool) {
796 /// Runs a change to the page, telling the system when it did not ask for it; returns
797 /// whether it changed.
798 @discardableResult
799 private func edit(external: Bool = false, _ change: () -> Bool) -> Bool {
699800 if external {
700801 inputDelegate?.selectionWillChange(self)
701802 inputDelegate?.textWillChange(self)
......@@ -712,6 +813,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
712813 }
713814 display.setNeedsSelectionUpdate()
714815 changedSelection()
816 return changed
715817 }
716818
717819 /// Tells the bar buttons and the format bar what the selection now has.
......@@ -736,6 +838,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
736838 override var inputAccessoryView: UIView? { formatBar }
737839
738840 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 }
739845 guard super.becomeFirstResponder() else { return false }
740846 if let handle { sb_view_focus(handle, true) }
741847 display.isActivated = true
......@@ -876,6 +982,37 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
876982 /// A conflict page, which shows what is stored and takes no edits.
877983 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
8791016 /// The title as typed; nil on a page without an editable title.
8801017 var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } }
8811018
......@@ -933,11 +1070,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
9331070 /// Selects paragraph `id` and brings it into view.
9341071 func selectParagraph(_ id: String) -> Bool {
9351072 guard let handle else { return false }
936 var found = false
937 edit(external: true) {
938 found = sb_view_select_paragraph(handle, id)
939 return found
940 }
1073 let found = edit(external: true) { sb_view_select_paragraph(handle, id) }
9411074 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
9421075 return found
9431076 }
......@@ -966,11 +1099,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
9661099 /// Selects the first place `query` occurs and brings it into view.
9671100 func find(_ query: String) -> Bool {
9681101 guard let handle else { return false }
969 var found = false
970 edit(external: true) {
971 found = sb_view_find(handle, query)
972 return found
973 }
1102 let found = edit(external: true) { sb_view_find(handle, query) }
9741103 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
9751104 return found
9761105 }
......@@ -978,12 +1107,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
9781107 /// Puts the caret at the end of the page title.
9791108 func focusTitle() -> Bool {
9801109 guard let handle else { return false }
981 var focused = false
982 edit(external: true) {
983 focused = sb_view_focus_title(handle)
984 return focused
985 }
986 return focused
1110 return edit(external: true) { sb_view_focus_title(handle) }
9871111 }
9881112
9891113 /// 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 {
7878 /// Looks the folder up off the main thread, as asking iCloud can take a while.
7979 static func lookUp() {
8080 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
8187 guard
8288 let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?
8389 .appendingPathComponent("Documents", isDirectory: true)
apps/ios/Snowbound/Lists.swift+102-39
......@@ -133,6 +133,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
133133
134134 func reload() {
135135 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 }
136143 let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) }
137144 let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : []
138145 let cloud: [(Location, [Notebook], [Item])] =
......@@ -186,6 +193,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
186193 reload()
187194 }
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
189204 private func showOnDevice(_ shown: Bool) {
190205 Notebooks.showsOnDevice = shown
191206 rescan()
......@@ -239,7 +254,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
239254 content.image = UIImage(
240255 systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")
241256 content.imageProperties.tintColor = tab.uiColor
242 if !tab.readable {
257 if tab.locked {
258 content.secondaryText = "Password protected"
259 } else if !tab.readable {
243260 content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here"
244261 content.textProperties.color = .secondaryLabel
245262 }
......@@ -370,7 +387,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
370387 return closed ? nil : problem ?? ""
371388 }) { [weak self] problem in
372389 if let problem {
373 self?.refuse(title, problem)
390 self?.alert(title, problem)
374391 return
375392 }
376393 notebook.pause()
......@@ -387,7 +404,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
387404 isFile ? name + "." + folder.pathExtension : name)
388405 guard target != folder else { return }
389406 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.")
391408 return
392409 }
393410 self?.close(notebook, refusing: "Can’t Rename") {
......@@ -395,7 +412,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
395412 try FileManager.default.moveItem(at: folder, to: target)
396413 _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil)
397414 } catch {
398 self?.refuse("Can’t Rename", error.localizedDescription)
415 self?.alert("Can’t Rename", error.localizedDescription)
399416 }
400417 self?.rescan()
401418 }
......@@ -406,10 +423,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
406423 field.clearButtonMode = .whileEditing
407424 NotificationCenter.default.addObserver(
408425 forName: UITextField.textDidChangeNotification, object: field, queue: .main
409 ) { _ in
410 let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
411 rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
412 }
426 ) { _ in rename.isEnabled = Self.namesFile(field) }
413427 }
414428 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
415429 alert.addAction(rename)
......@@ -417,11 +431,17 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
417431 present(alert, animated: true)
418432 }
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
420440 /// Copies the notebook beside itself, named as Files names a duplicate.
421441 private func duplicate(_ folder: URL, of notebook: Notebook) {
422442 // The copy takes what the open sections have stored.
423443 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.")
425445 }
426446 let base = folder.deletingPathExtension().lastPathComponent
427447 let ext = folder.pathExtension
......@@ -436,7 +456,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
436456 do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription }
437457 return nil
438458 }) { [weak self] problem in
439 if let problem { self?.refuse("Can’t Duplicate", problem) }
459 if let problem { self?.alert("Can’t Duplicate", problem) }
440460 self?.rescan()
441461 }
442462 }
......@@ -475,7 +495,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
475495 })
476496 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
477497 do { try FileManager.default.removeItem(at: folder) } catch {
478 self?.refuse("Can’t Delete", error.localizedDescription)
498 self?.alert("Can’t Delete", error.localizedDescription)
479499 }
480500 self?.rescan()
481501 })
......@@ -495,7 +515,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
495515 guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
496516 switch item {
497517 case .section(let id, let path):
498 return notebook(id)?.tabs.first(where: { $0.path == path })?.readable == true
518 let tab = notebook(id)?.tabs.first(where: { $0.path == path })
519 return tab?.readable == true || tab?.locked == true
499520 case .status(let id):
500521 guard let notebook = notebook(id), notebook.problem != nil else { return false }
501522 let spinner = UIActivityIndicatorView(style: .medium)
......@@ -532,9 +553,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
532553 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
533554 let tab = notebook.tabs.first(where: { $0.path == path })
534555 else { return }
556 if tab.locked { return unlock(tab, of: notebook, wrong: false) }
535557 onOpen?(tab, notebook)
536558 }
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
538586 private func add(_ notebook: Notebook) {
539587 Notebooks.add(notebook)
540588 openFirstSection(of: notebook)
......@@ -572,10 +620,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
572620 field.autocapitalizationType = .words
573621 NotificationCenter.default.addObserver(
574622 forName: UITextField.textDidChangeNotification, object: field, queue: .main
575 ) { _ in
576 let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
577 create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
578 }
623 ) { _ in create.isEnabled = Self.namesFile(field) }
579624 }
580625 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
581626 alert.addAction(create)
......@@ -587,7 +632,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
587632 let place = inCloud ? ICloud.documents : documentsDirectory
588633 guard let folder = place?.appendingPathComponent(name) else { return }
589634 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.")
591636 }
592637 let (date, time) = titleDate()
593638 background({ () -> String? in
......@@ -597,14 +642,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
597642 return made ? nil : problem ?? ""
598643 }) { [weak self] problem in
599644 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) }
601646 if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
602 rescan { [weak self] notebook in
603 guard notebook.source == (inCloud ? .icloud(path: name) : .documents(path: name)),
604 let tab = notebook.tabs.first(where: \.readable)
605 else { return }
606 self?.onOpen?(tab, notebook)
607 }
647 rescan(opening: inCloud ? .icloud(path: name) : .documents(path: name))
608648 }
609649 }
610650
......@@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
621661 return nil
622662 }) { [weak self] problem in
623663 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) }
625665 if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
626 rescan { [weak self] notebook in
627 guard notebook.source == .documents(path: Notebooks.guide),
628 let tab = notebook.tabs.first(where: \.readable)
629 else { return }
630 self?.onOpen?(tab, notebook)
631 }
666 rescan(opening: .documents(path: Notebooks.guide))
632667 }
633668 }
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
641670 private func openFolder() {
642671 let picker = UIDocumentPickerViewController(
643672 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
......@@ -721,6 +750,11 @@ final class PagesViewController: UITableViewController {
721750 /// Opens a page of the section.
722751 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
724758 init() {
725759 super.init(style: .plain)
726760 }
......@@ -740,6 +774,25 @@ final class PagesViewController: UITableViewController {
740774 ]
741775 NotificationCenter.default.addObserver(
742776 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
743796 }
744797
745798 override func viewWillAppear(_ animated: Bool) {
......@@ -751,6 +804,10 @@ final class PagesViewController: UITableViewController {
751804 section = nil
752805 items = []
753806 title = tab.name
807 if Prototype.sectionStrip, let notebook = strip.notebook {
808 title = notebook.name
809 strip.select(tab.path)
810 }
754811 tableView.reloadData()
755812 var loading = UIContentUnavailableConfiguration.loading()
756813 loading.text = "Opening…"
......@@ -768,7 +825,7 @@ final class PagesViewController: UITableViewController {
768825 navigationController?.setToolbarHidden(true, animated: false)
769826 }
770827
771 func failed(_ tab: Tab, _ problem: String?) {
828 func failed(_ problem: String?) {
772829 var empty = UIContentUnavailableConfiguration.empty()
773830 empty.text = "Can’t Open Section"
774831 empty.secondaryText = problem
......@@ -778,6 +835,10 @@ final class PagesViewController: UITableViewController {
778835 func load(_ section: Section) {
779836 self.section = section
780837 title = section.tab.name
838 if Prototype.sectionStrip {
839 title = section.notebook.name
840 strip.show(section.notebook, selected: section.tab.path)
841 }
781842 navigationController?.setToolbarHidden(false, animated: false)
782843 reload()
783844 }
......@@ -816,7 +877,7 @@ final class PagesViewController: UITableViewController {
816877 }
817878
818879 private func delete(_ item: Item) {
819 section?.delete(item.id) { _ in }
880 section?.delete(item.id)
820881 }
821882
822883 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
......@@ -859,6 +920,8 @@ final class PagesViewController: UITableViewController {
859920 override func tableView(
860921 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
861922 ) -> UISwipeActionsConfiguration? {
923 // A swipe across the list changes section instead; the context menu deletes.
924 if Prototype.sectionStrip { return nil }
862925 let item = items[indexPath.row]
863926 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
864927 self?.delete(item)
apps/ios/Snowbound/Notebooks.swift+66-44
......@@ -30,7 +30,11 @@ enum Author {
3030
3131 static var name: String? {
3232 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 }
3438 }
3539
3640 /// Asks for the user name OneNote shows beside changes, then runs `then`.
......@@ -126,13 +130,22 @@ struct Tab: Decodable, Equatable {
126130 let group: String
127131 let color: [UInt8]
128132 let readable: Bool
133 /// Password protected and not unlocked: `Notebook.unlock` opens it.
134 let locked: Bool
129135 /// Not on this device yet; `Notebook` asks iCloud Drive for it.
130136 let downloading: Bool
131137 /// Why the file could not be read, where retrying or repair may help.
132138 let problem: String?
133139
134 var uiColor: UIColor {
135 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
140 var uiColor: UIColor { UIColor(rgb: color) }
141}
142
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)
136149 }
137150}
138151
......@@ -205,13 +218,8 @@ final class Notebook {
205218 }
206219 let accessed = url.startAccessingSecurityScopedResource() ? url : nil
207220 let local = onThisDevice(url)
208 var library: OpaquePointer?
209 // A coordinated read brings a cloud folder's files down before they are read.
210 var coordination: NSError?
211 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
212 library = sb_library_open(url.path, cacheDirectory.path, local, &error)
213 }
214 return (library, accessed, take(error) ?? coordination?.localizedDescription, local ? url : nil)
221 let (library, problem) = coordinatedOpen(url, local: local)
222 return (library, accessed, problem, local ? url : nil)
215223 case .documents(let path):
216224 let url = documentsDirectory.appendingPathComponent(path)
217225 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
......@@ -220,12 +228,8 @@ final class Notebook {
220228 guard let url = ICloud.documents?.appendingPathComponent(path) else {
221229 return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil)
222230 }
223 var library: OpaquePointer?
224 var coordination: NSError?
225 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
226 library = sb_library_open(url.path, cacheDirectory.path, false, &error)
227 }
228 return (library, nil, take(error) ?? coordination?.localizedDescription, url)
231 let (library, problem) = coordinatedOpen(url, local: false)
232 return (library, nil, problem, url)
229233 case .path(let path):
230234 let url = URL(fileURLWithPath: path)
231235 let local = onThisDevice(url)
......@@ -262,6 +266,21 @@ final class Notebook {
262266 }
263267 }
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
265284 /// Lists the sections again, off the main thread.
266285 func reload(done: @escaping () -> Void) {
267286 guard let handle else {
......@@ -323,6 +342,18 @@ final class Notebook {
323342 }
324343}
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
326357/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
327358/// too, as iCloud Drive does.
328359private func onThisDevice(_ url: URL) -> Bool {
......@@ -414,7 +445,7 @@ enum Notebooks {
414445 set { UserDefaults.standard.set(!newValue, forKey: hidden) }
415446 }
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.
418449 static func load() {
419450 if let scripted {
420451 elsewhere = [
......@@ -435,12 +466,6 @@ enum Notebooks {
435466 return Notebook(id: entry.id, name: entry.name, source: entry.source)
436467 }
437468 } 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 }
444469 // The notebook the first version remembered from Files.
445470 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
446471 var stale = false
......@@ -456,34 +481,31 @@ enum Notebooks {
456481 /// Lists Documents again, keeping the notebooks still there; returns those new to the list.
457482 @discardableResult
458483 static func scan() -> [Notebook] {
459 guard showsOnDevice else {
460 onDevice = []
461 return []
462 }
463 var added: [Notebook] = []
464 onDevice = notebooks(in: documentsDirectory).map { name in
465 if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept }
466 let notebook = Notebook(
467 id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name))
468 added.append(notebook)
469 return notebook
470 }
471 return added
484 let folder = showsOnDevice ? documentsDirectory : nil
485 return rescan(folder, "Documents/", &onDevice) { .documents(path: $0) }
472486 }
473487
474488 /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while
475489 /// iCloud Drive is off.
476490 @discardableResult
477491 static func scanICloud() -> [Notebook] {
478 guard let folder = ICloud.documents, scripted == nil else {
479 inCloud = []
492 rescan(scripted == nil ? ICloud.documents : nil, "iCloud/", &inCloud) { .icloud(path: $0) }
493 }
494
495 /// Lists `folder`'s notebooks into `list`, keeping those still there and naming new ones by
496 /// `prefix` and their `source`; returns the new ones.
497 private static func rescan(
498 _ folder: URL?, _ prefix: String, _ list: inout [Notebook], source: (String) -> Source
499 ) -> [Notebook] {
500 guard let folder else {
501 list = []
480502 return []
481503 }
482504 var added: [Notebook] = []
483 inCloud = notebooks(in: folder).map { name in
484 if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept }
505 list = notebooks(in: folder).map { [list] name in
506 if let kept = list.first(where: { $0.id == prefix + name }) { return kept }
485507 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))
487509 added.append(notebook)
488510 return notebook
489511 }
......@@ -650,13 +672,13 @@ final class Section {
650672 return id
651673 }
652674
653 func delete(_ id: String, done: @escaping (Bool) -> Void) {
675 func delete(_ id: String, done: @escaping () -> Void = {}) {
654676 let (date, time) = titleDate()
655677 let pointer = Int(bitPattern: handle)
656 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in
678 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in
657679 reloadRows()
658680 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
659 done(deleted)
681 done()
660682 }
661683 }
662684
apps/ios/Snowbound/PageViewController.swift+32-16
......@@ -21,6 +21,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
2121 private let titleFocus: Bool
2222 private lazy var canvas = CanvasView(section: section, page: page)
2323 private let bar = ConflictBar()
24 /// Reading View, beside the page menu where the page has one.
25 private var reader: UIBarButtonItem?
2426 /// Opens another page or conflict page of the section.
2527 var onOpen: ((String) -> Void)?
2628 private lazy var done = UIBarButtonItem(
......@@ -96,6 +98,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
9698 if revealed || titleFocus && canvas.focusTitle() {
9799 _ = canvas.becomeFirstResponder()
98100 }
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 }
99107 }
100108
101109 private func showTitle(_ title: String) {
......@@ -121,8 +129,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
121129 undo.isEnabled = canvas.canUndo(redo: false)
122130 redo.isEnabled = canvas.canUndo(redo: true)
123131 // Drawing keeps undo at hand, as typing does.
132 reader?.isSelected = canvas.readingShown
124133 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 }
126136 return
127137 }
128138 let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo]
......@@ -164,7 +174,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
164174 title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable
165175 ) { [weak self] _ in self?.canvas.insertSpace() },
166176 ])
167 return [
177 return (Prototype.reading ? [readingAction()] : []) + [
168178 insert,
169179 UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) {
170180 [weak self] _ in self?.showPaper()
......@@ -189,12 +199,27 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
189199 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
190200 [weak self] _ in
191201 guard let self else { return }
192 section.delete(page) { _ in }
202 section.delete(page)
193203 },
194204 ]),
195205 ]
196206 }
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
198223 /// The page's theme, its section's and its notebook's, each pickable; the page wears a
199224 /// new one when it opens again.
200225 private func themeMenu() -> UIMenu {
......@@ -228,12 +253,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
228253 let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89)
229254 let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? ""
230255 guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else {
231 let alert = UIAlertController(
232 title: export ? "Couldn't Export the PDF" : "Couldn't Print",
233 message: "The page could not be laid out on paper.", preferredStyle: .alert)
234 alert.addAction(UIAlertAction(title: "OK", style: .default))
235 present(alert, animated: true)
236 return
256 return alert(
257 export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.")
237258 }
238259 if export {
239260 let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-")
......@@ -274,12 +295,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
274295 // Nothing typed is lost unannounced: the page's text goes on the clipboard.
275296 UIPasteboard.general.string = canvas.pageText
276297 canvas.reload(discard: true)
277 let alert = UIAlertController(
278 title: "Change Not Saved",
279 message: "The page shows what was last saved. Your text is on the clipboard to paste back.",
280 preferredStyle: .alert)
281 alert.addAction(UIAlertAction(title: "OK", style: .default))
282 present(alert, animated: true)
298 alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.")
283299 } else if flags & Section.remote != 0 {
284300 canvas.reload(discard: false)
285301 }
......@@ -332,7 +348,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
332348 private func conflictAction() {
333349 guard let (row, version) = section.row(of: page) else { return }
334350 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) }
336352 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {
337353 onOpen?(newest.id)
338354 }
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 {
1414 /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the
1515 /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes
1616 /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done`
17 /// ends editing, `tree` saves the view hierarchy to
17 /// ends editing, `reading` turns the reading view on or off, `tree` saves the view hierarchy to
1818 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
1919 /// screenshot command.
2020 func runScript() {
......@@ -73,8 +73,9 @@ extension CanvasView {
7373 from: position(from: beginningOfDocument, offset: values[0])!,
7474 to: position(from: beginningOfDocument, offset: values[1])!)
7575 case "scroll":
76 let values = argument.split(separator: ",").compactMap { Double($0) }
77 setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true)
76 // `scroll:,Y` keeps the horizontal offset.
77 let values = argument.split(separator: ",", omittingEmptySubsequences: false).map { Double($0) }
78 setContentOffset(CGPoint(x: values[0] ?? contentOffset.x, y: values[1] ?? contentOffset.y), animated: true)
7879 case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)
7980 case "doubletap":
8081 let values = argument.split(separator: ",").compactMap { Double($0) }
......@@ -90,6 +91,7 @@ extension CanvasView {
9091 menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
9192 case "done": _ = resignFirstResponder()
9293 case "selectall": selectAll(nil)
94 case "reading": showReading(!readingShown)
9395 case "format": apply(UInt8(argument) ?? 0)
9496 case "find": _ = find(argument)
9597 case "title": _ = focusTitle()
apps/ios/Snowbound/Server.swift-6
......@@ -110,12 +110,6 @@ final class ServerViewController: UITableViewController, UITextFieldDelegate {
110110 }
111111 }
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
119113 override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 }
120114
121115 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.
8484 Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags
8585 sit in a column to the left of the text with OneNote's spacing, and a tag's
8686 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
8893These rules were established against OneNote's own output: its XML export
8994gives 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.
182182
183183## Protected sections
184184
185Password-protected sections keep their encrypted structure. With the
186`protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied
187password. Edits are sealed back under the section's key, with a fresh IV for
188each object. Plaintext never reaches the replica's cache. A protected section is
189edited directly in its file and never queued.
185A password-protected section keeps its structure in the clear and encrypts every
186object and payload under one AES-128 key, which Office's Agile password encryption
187wraps (SHA-1, 100,000 rounds). `protected::Key` opens it with the password, and
188`Section::unlock` keeps the section open under it: objects decode into the arena as it
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
191199## Looking inside
192200
arc/platforms.md+20-8
......@@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system.
1010shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios)
1111glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI)
1212page 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)
1414data notebook::session + embedded SMB client ────────────────────────────────────►
1515format onestore ───────────────────────────────────────────────────────────────────►
1616```
......@@ -103,9 +103,15 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
103103 imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8
104104 and later that dependencies still name are answered by
105105 `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:
107107 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.
109115- The toolbar's row is the title bar wherever the desktop composes windows.
110116 On Windows 7 with Aero and on 11, the system's frame keeps its sides,
111117 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.
115121 draws them as 11 does, lit where the system reports the pointer. On 8
116122 and 10, whose frames are opaque, the window has no system frame and the
117123 row draws and runs caption buttons as 10 does, over acrylic on 10. Over
118 7's glass the sidebar stays opaque, and fields and tool buttons take white
119 faces, as Internet Explorer's do there. The theme and material follow the
120 system's colour mode as it changes. With Windows 7's basic or classic
121 theme the system draws the title bar and the row lies beneath it, as on
122 KDE.
124 7's glass, which takes any colour and whatever lies behind the window,
125 the toolbar keeps opaque faces under its icons: by default the strip is
126 opaque, as Explorer's command bar is under its glass, stopping short of the
127 caption buttons 7 draws beneath the window's pixels, and
128 `SNOWBOUND_W7_CHROME` tries the alternatives, `pills` for a face per group
129 of tools over the glass, `frost` for a strip the glass tints through, and
130 `tint` and `tint-tiles` for one panel or a tile per group in the glass's
131 colour at a lightness text keeps its contrast on, as Mica tints.
132 The theme and material follow the system's colour mode as it changes.
133 With Windows 7's basic or classic theme the system draws the title bar
134 and the row lies beneath it, as on KDE.
123135- A notebook on a share opens by its UNC path through Windows' own SMB
124136 client, which takes OneNote's opens and locks natively: `onestore` opens a
125137 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:
5555- **payloads**: picture and attachment bytes, stored once each, keyed by hash.
5656
5757The 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 WAL
58replayed 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
5967mode with full synchronous commits, and every setting is read back to check it
6068took.
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
55and `read/page-000.xml` retain the native observations. The saved store uses
66visual left-to-right column order [Right cell, Left cell], with widths [144, 96].
77COM 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:
9store x + LayoutMaxWidth - PageMarginOriginX - 36 equals native x + native width.
10The native table outline measures wider than its stored suggested maximum width.
8The native PDF independently confirms the physical cell order. Native x is store x -
9PageMarginOriginX - 36; the native table outline measures wider than its stored suggested
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).
1112Vertical coordinates retain the 14.4-point canonical origin.
1213
1314The 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 @@
22
33`candidate/notebook` is `native-encrypted/encrypted-01` after
44a 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`
6(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a
5paragraph, exported by the protected writer that `Section::unlock` has since replaced
6(`corpus/protected-sections` gates the current one). `candidate/read` is OneNote 2010's COM read from a
77fresh clone with an empty cache after unlocking the section in its UI with the
88fixture 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
4646name = "render_page"
4747required-features = ["gpu"]
4848
49[[example]]
50name = "reading_inventory"
51required-features = ["gpu"]
52
4953[[example]]
5054name = "print_pdf"
5155required-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
544544 probe(&mut editor, engine, inventory, place);
545545}
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) {
548579 let Ok(image) = std::fs::read(path) else {
549580 return;
550581 };
......@@ -554,7 +585,15 @@ fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut In
554585 .display()
555586 .to_string();
556587 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 {
558597 Ok(section) => section,
559598 Err(error) => {
560599 inventory.add(
......@@ -612,6 +651,10 @@ fn main() {
612651 let mut inventory = Inventory::default();
613652 let mut seen = BTreeSet::new();
614653 let mut duplicates = 0;
654 let mut known = Vec::new();
655 for root in &roots {
656 passwords(root, &mut known);
657 }
615658 for root in &roots {
616659 let mut paths = Vec::new();
617660 sections(root, &mut paths);
......@@ -625,7 +668,7 @@ fn main() {
625668 duplicates += 1;
626669 continue;
627670 }
628 section(&mut engine, &path, root, &mut inventory);
671 section(&mut engine, &path, root, &known, &mut inventory);
629672 }
630673 }
631674 println!("# Unsupported content inventory\n");
crates/canvas/src/conflict.rs+14-30
......@@ -1,5 +1,6 @@
11//! Conflict pages and page versions as OneNote shows them.
22
3use crate::document::descendants;
34use onestore::{
45 ExGuid,
56 page::{Page, PageObject, PageParagraph, ParagraphContent},
......@@ -21,9 +22,19 @@ pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page {
2122/// banded as OneNote shows it; all of it without one.
2223pub fn changes(version: Page, older: Option<&Page>) -> Page {
2324 let texts = |page: &Page| {
24 let mut texts = Vec::new();
25 each_text(page, &mut |id, text| texts.push((id, text.to_owned())));
26 texts
25 page.objects
26 .iter()
27 .flat_map(|object| match object {
28 PageObject::Outline(outline) => std::slice::from_ref(outline),
29 PageObject::Title(title) => title.outlines.as_slice(),
30 _ => &[],
31 })
32 .flat_map(|outline| descendants(&outline.paragraphs, None))
33 .filter_map(|(_, _, node)| match &node.content {
34 ParagraphContent::Text(text) => Some((text.id, text.text.text().to_owned())),
35 _ => None,
36 })
37 .collect::<Vec<_>>()
2738 };
2839 let before = older.map(texts).unwrap_or_default();
2940 let changed: Vec<ExGuid> = texts(&version)
......@@ -34,33 +45,6 @@ pub fn changes(version: Page, older: Option<&Page>) -> Page {
3445 banded(version, &changed, CHANGED)
3546}
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
6448fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page {
6549 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) {
6650 for paragraph in paragraphs {
crates/canvas/src/date.rs+1-1
......@@ -32,7 +32,7 @@ impl PageDate {
3232 &self.layout
3333 }
3434
35 pub fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> {
35 pub(crate) fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> {
3636 let swapped = self
3737 .source
3838 .paragraphs
crates/canvas/src/document.rs+5-5
......@@ -26,11 +26,11 @@ pub struct TextDocument {
2626#[derive(Clone, Debug, PartialEq)]
2727pub(crate) struct DocumentEdit {
2828 /// Cell identity, or None for the document root.
29 pub container: Option<ExGuid>,
30 pub range: Range<usize>,
31 pub replacement: Vec<PageParagraph>,
29 pub(crate) container: Option<ExGuid>,
30 pub(crate) range: Range<usize>,
31 pub(crate) replacement: Vec<PageParagraph>,
3232 /// Width patches address surviving tables outside the replacement subtree.
33 pub columns: BTreeMap<ExGuid, Vec<f32>>,
33 pub(crate) columns: BTreeMap<ExGuid, Vec<f32>>,
3434}
3535
3636pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> {
......@@ -757,7 +757,7 @@ impl TextDocument {
757757 Err(EditError::InvalidRange)
758758 }
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> {
761761 if range.start > range.end {
762762 return Err(EditError::InvalidRange);
763763 }
crates/canvas/src/editor.rs+129-119
......@@ -100,10 +100,7 @@ pub struct Awaited {
100100}
101101
102102/// A new picture of encoded `bytes`, `size` points large.
103pub(crate) fn picture(
104 bytes: Vec<u8>,
105 size: [f32; 2],
106) -> Result<onestore::page::Image, EditError> {
103pub fn picture(bytes: Vec<u8>, size: [f32; 2]) -> Result<onestore::page::Image, EditError> {
107104 Ok(onestore::page::Image {
108105 id: onestore::page::text::new_id()?,
109106 layout: onestore::document::Layout {
......@@ -187,6 +184,7 @@ struct PageHeader {
187184 identity: Option<[u8; 16]>,
188185 created: Option<u64>,
189186 margin_origin: [f32; 2],
187 rtl: bool,
190188 color: Option<u32>,
191189 rule_lines: Option<onestore::page::RuleLines>,
192190 areas: Vec<page::TitleArea>,
......@@ -378,7 +376,7 @@ impl TextOutline {
378376 }
379377
380378 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)
382380 }
383381
384382 pub fn wrap_width(&self) -> f32 {
......@@ -509,7 +507,7 @@ impl TextOutline {
509507 }
510508
511509 /// All the outline's shown text.
512 pub fn whole(&self) -> Selection {
510 pub(crate) fn whole(&self) -> Selection {
513511 let paragraph = self.source_index(self.shaped.paragraphs.len() - 1);
514512 let last = self.document.paragraph(paragraph).unwrap();
515513 [
......@@ -587,7 +585,11 @@ impl TextOutline {
587585
588586 /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
589587 /// 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> {
591593 let paragraph = self.paragraph_layout(index)?;
592594 let selection = ParagraphSelection::new(
593595 paragraph.cursor(range.start, Affinity::Downstream)?,
......@@ -935,39 +937,15 @@ impl CanvasEditor {
935937 width: f32,
936938 ) -> Result<Self, EditorError> {
937939 let outline = TextOutline::new(engine, document, width, [0.0; 2])?;
938 let (outlines, active) = if outline.is_empty() {
939 (
940 Vec::new(),
941 Focus::Caret {
942 outline: Box::new(outline),
943 index: 0,
944 },
945 )
946 } else {
947 (vec![outline], Focus::Outline(0))
948 };
949 Ok(Self {
950 outlines,
951 definitions: BTreeMap::new(),
952 objects: Vec::new(),
953 date: None,
954 header: PageHeader::default(),
955 active,
956 undo: Vec::new(),
957 redo: Vec::new(),
958 composition: None,
959 preferred_x: None,
960 pending: None,
961 ops: Ok(Vec::new()),
962 stored: None,
963 whole: None,
964 typing: None,
965 recording: None,
966 ink_drag: None,
967 provisional: BTreeMap::new(),
968 default_font: DefaultFont::default(),
969 styles: BTreeMap::new(),
970 })
940 let empty = outline.is_empty();
941 let mut editor = Self::from_text_outlines(vec![outline], BTreeMap::new(), None)?;
942 if empty {
943 editor.active = Focus::Caret {
944 outline: Box::new(editor.outlines.pop().unwrap()),
945 index: 0,
946 };
947 }
948 Ok(editor)
971949 }
972950
973951 pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> {
......@@ -1014,6 +992,7 @@ impl CanvasEditor {
1014992 identity: page.identity,
1015993 created: page.created,
1016994 margin_origin: page.margin_origin,
995 rtl: page.rtl,
1017996 color: page.color,
1018997 rule_lines: page.rule_lines,
1019998 areas,
......@@ -1317,6 +1296,7 @@ impl CanvasEditor {
13171296 .map(PageDate::timestamp)
13181297 .or(self.header.created),
13191298 margin_origin: self.header.margin_origin,
1299 rtl: self.header.rtl,
13201300 color: self.header.color,
13211301 rule_lines: self.header.rule_lines,
13221302 objects,
......@@ -1516,7 +1496,8 @@ impl CanvasEditor {
15161496 .collect()
15171497 }
15181498
1519 pub fn from_outlines(
1499 #[cfg(test)]
1500 pub(crate) fn from_outlines(
15201501 engine: &mut TextEngine,
15211502 outlines: Vec<Outline>,
15221503 definitions: BTreeMap<ExGuid, Definition>,
......@@ -1631,14 +1612,15 @@ impl CanvasEditor {
16311612 return false;
16321613 }
16331614 self.finish_composition();
1634 let mut entries = Vec::new();
1615 let depth = self.undo.len();
16351616 if paper {
1636 entries.push(self.paper(color, rule_lines));
1617 let entry = self.paper(color, rule_lines);
1618 self.undo.push(entry);
16371619 }
16381620 if let Some(art) = art {
16391621 for index in backgrounds.into_iter().rev() {
16401622 let image = self.remove_picture(index);
1641 entries.push(History::Picture {
1623 self.undo.push(History::Picture {
16421624 index,
16431625 image: Some(Box::new(image)),
16441626 });
......@@ -1646,19 +1628,13 @@ impl CanvasEditor {
16461628 // Art lies under everything else on the page.
16471629 for image in art.into_iter().rev() {
16481630 self.add_picture(0, image);
1649 entries.push(History::Picture {
1631 self.undo.push(History::Picture {
16501632 index: 0,
16511633 image: None,
16521634 });
16531635 }
16541636 }
1655 self.undo.push(match entries.len() {
1656 1 => entries.pop().unwrap(),
1657 _ => History::Group {
1658 entries,
1659 page: false,
1660 },
1661 });
1637 self.group(depth, false);
16621638 self.redo.clear();
16631639 true
16641640 }
......@@ -1748,7 +1724,6 @@ impl CanvasEditor {
17481724 }
17491725 }
17501726
1751 /// Provisional input geometry outside the document's outline collection.
17521727 /// The caret a run of typing left while typing there still joins the run: the word
17531728 /// before it is still being written.
17541729 pub fn typing(&self) -> Option<(ExGuid, TextPosition)> {
......@@ -1759,6 +1734,7 @@ impl CanvasEditor {
17591734 .then_some((outline, at))
17601735 }
17611736
1737 /// Provisional input geometry outside the document's outline collection.
17621738 pub fn caret_outline(&self) -> Option<&TextOutline> {
17631739 match &self.active {
17641740 Focus::Caret { outline, .. } => Some(outline),
......@@ -2020,9 +1996,8 @@ impl CanvasEditor {
20201996 return Some(([x0, y0], [x1 - x0, y1 - y0]));
20211997 }
20221998 if let Some(image) = self.image(id) {
2023 let layout = &image.layout;
20241999 return Some((
2025 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],
2000 crate::origin(&image.layout),
20262001 crate::outline::image_size(image)?,
20272002 ));
20282003 }
......@@ -2034,7 +2009,7 @@ impl CanvasEditor {
20342009 }
20352010
20362011 /// 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 {
20382013 self.outline_picture(id).is_some()
20392014 }
20402015
......@@ -2052,7 +2027,7 @@ impl CanvasEditor {
20522027 }
20532028
20542029 /// 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> {
20562031 let image = match self.image(id) {
20572032 Some(image) => image,
20582033 None => match &self.outline_picture(id)?.3.content {
......@@ -2064,7 +2039,7 @@ impl CanvasEditor {
20642039 }
20652040
20662041 /// 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> {
20682043 if let Some((file, _)) = self.page_file(id) {
20692044 return Some(file);
20702045 }
......@@ -2083,15 +2058,14 @@ impl CanvasEditor {
20832058 }
20842059
20852060 /// 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]> {
20872062 if let Some((_, rect)) = self.page_file(id) {
20882063 return Some(rect);
20892064 }
20902065 let (outline, ..) = self.outline_picture(id)?;
20912066 let outline = self.outlines.iter().find(|item| item.id == outline)?;
2092 let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds();
2093 let [x, y] = outline.origin();
2094 Some([x + x0, y + y0, x + x1, y + y1])
2067 let bounds = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds();
2068 Some(crate::translated(bounds, outline.origin()))
20952069 }
20962070
20972071 /// Replaces a picture paragraph through the outline's text history.
......@@ -2231,7 +2205,7 @@ impl CanvasEditor {
22312205 /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the
22322206 /// text outline before it in page order, or to the start of the one after. False when there
22332207 /// is none.
2234 pub fn step_from_image(
2208 pub(crate) fn step_from_image(
22352209 &mut self,
22362210 engine: &mut TextEngine,
22372211 id: ExGuid,
......@@ -2394,15 +2368,7 @@ impl CanvasEditor {
23942368 break;
23952369 }
23962370 }
2397 let entries = self.undo.split_off(depth);
2398 if entries.len() > 1 {
2399 self.undo.push(History::Group {
2400 entries,
2401 page: false,
2402 });
2403 } else {
2404 self.undo.extend(entries);
2405 }
2371 self.group(depth, false);
24062372 result.map(|()| awaited)
24072373 }
24082374
......@@ -2414,7 +2380,11 @@ impl CanvasEditor {
24142380 at: Awaited,
24152381 image: onestore::page::Image,
24162382 ) -> 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 {
24182388 return Ok(false);
24192389 };
24202390 let Some((container, index, node)) = descendants(outline.document.nodes(), None)
......@@ -2605,6 +2575,12 @@ impl CanvasEditor {
26052575 self.header.margin_origin
26062576 }
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
26082584 pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> {
26092585 let outline = self.active_outline();
26102586 if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width)
......@@ -2760,7 +2736,7 @@ impl CanvasEditor {
27602736 }
27612737
27622738 /// 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> {
27642740 self.focus_outline(id)?;
27652741 let whole = self.active_outline().whole();
27662742 self.select(whole)?;
......@@ -2793,20 +2769,38 @@ impl CanvasEditor {
27932769 Ok(())
27942770 }
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.
27972797 fn grouped<T>(
27982798 &mut self,
27992799 edit: impl FnOnce(&mut Self) -> Result<T, EditorError>,
28002800 ) -> Result<T, EditorError> {
28012801 let depth = self.undo.len();
28022802 let result = edit(self);
2803 let entries = self.undo.split_off(depth);
2804 if !entries.is_empty() {
2805 self.undo.push(History::Group {
2806 entries,
2807 page: true,
2808 });
2809 }
2803 self.group(depth, true);
28102804 result
28112805 }
28122806
......@@ -2828,13 +2822,7 @@ impl CanvasEditor {
28282822 }
28292823 let depth = self.undo.len();
28302824 let result = self.delete(engine, false).and_then(|_| edit(self, engine));
2831 let entries = self.undo.split_off(depth);
2832 if !entries.is_empty() {
2833 self.undo.push(History::Group {
2834 entries,
2835 page: false,
2836 });
2837 }
2825 self.group(depth, false);
28382826 result.map(Some)
28392827 }
28402828
......@@ -2858,27 +2846,8 @@ impl CanvasEditor {
28582846 });
28592847 let depth = self.undo.len() - usize::from(joins);
28602848 let result = edit(self);
2861 let (mut run, mut page) = (Vec::new(), false);
2862 for entry in self.undo.split_off(depth) {
2863 match entry {
2864 History::Group {
2865 entries,
2866 page: selected,
2867 } => {
2868 page |= selected;
2869 if run.is_empty() {
2870 run = entries;
2871 } else {
2872 run.extend(entries);
2873 }
2874 }
2875 entry => run.push(entry),
2876 }
2877 }
2878 match run.len() {
2879 0 => return result,
2880 1 if !page => self.undo.extend(run),
2881 _ => self.undo.push(History::Group { entries: run, page }),
2849 if !self.group(depth, false) {
2850 return result;
28822851 }
28832852 if result.is_ok() {
28842853 let caret = self.selection().positions[1];
......@@ -2957,7 +2926,7 @@ impl CanvasEditor {
29572926 })
29582927 }
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> {
29612930 let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;
29622931 if extend {
29632932 selection.positions[0] = self.selection().positions[0];
......@@ -3448,12 +3417,12 @@ impl CanvasEditor {
34483417 Ok(())
34493418 }
34503419
3451 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
3420 pub(crate) fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
34523421 self.active_outline()
34533422 .range_rects(self.active_outline().selection)
34543423 }
34553424
3456 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
3425 pub(crate) fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
34573426 match &self.composition {
34583427 Some(composition) => self.active_outline().range_rects(Selection {
34593428 positions: [composition.range.start, composition.range.end],
......@@ -3506,7 +3475,11 @@ impl CanvasEditor {
35063475
35073476 /// Replaces the selection with `text` in the formatting of the text it replaces, as a
35083477 /// 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> {
35103483 let [anchor, focus] = self.selection().positions;
35113484 let end = anchor.max(focus);
35123485 let format = self
......@@ -3752,7 +3725,7 @@ impl CanvasEditor {
37523725
37533726 /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the
37543727 /// 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(
37563729 &mut self,
37573730 engine: &mut TextEngine,
37583731 up: bool,
......@@ -3994,7 +3967,7 @@ impl CanvasEditor {
39943967
39953968 /// Takes up `parked`, the editor this page had when it was last left, history and all,
39963969 /// in place of this one: what storage changed since reaches it as `refresh` would.
3997 pub fn resume(
3970 pub(crate) fn resume(
39983971 &mut self,
39993972 mut parked: CanvasEditor,
40003973 engine: &mut TextEngine,
......@@ -4928,9 +4901,6 @@ impl CanvasEditor {
49284901 }
49294902}
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.
49344904/// Whether `content` is picture or file `id`.
49354905fn holds(content: &ParagraphContent, id: ExGuid) -> bool {
49364906 match content {
......@@ -4940,6 +4910,9 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool {
49404910 }
49414911}
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.
49434916fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
49444917 let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {
49454918 let paragraph = new
......@@ -6447,6 +6420,7 @@ mod tests {
64476420 identity: None,
64486421 created: None,
64496422 margin_origin: [36.0, 14.4],
6423 rtl: false,
64506424 color: None,
64516425 rule_lines: None,
64526426 definitions: BTreeMap::new(),
......@@ -6558,6 +6532,7 @@ mod tests {
65586532 identity: None,
65596533 created: None,
65606534 margin_origin: [36.0, 14.4],
6535 rtl: false,
65616536 color: None,
65626537 rule_lines: None,
65636538 definitions: BTreeMap::new(),
......@@ -6656,6 +6631,7 @@ mod tests {
66566631 identity: None,
66576632 created: None,
66586633 margin_origin: [36.0, 14.4],
6634 rtl: false,
66596635 color: None,
66606636 rule_lines: None,
66616637 definitions: BTreeMap::new(),
......@@ -6720,6 +6696,7 @@ mod tests {
67206696 identity: None,
67216697 created: None,
67226698 margin_origin: [36.0, 14.4],
6699 rtl: false,
67236700 color: None,
67246701 rule_lines: None,
67256702 definitions: BTreeMap::new(),
......@@ -6840,6 +6817,7 @@ mod tests {
68406817 identity: None,
68416818 created: None,
68426819 margin_origin: [36.0, 14.4],
6820 rtl: false,
68436821 color: None,
68446822 rule_lines: None,
68456823 definitions: BTreeMap::new(),
......@@ -6959,6 +6937,7 @@ mod tests {
69596937 identity: None,
69606938 created: None,
69616939 margin_origin: [36.0, 14.4],
6940 rtl: false,
69626941 color: None,
69636942 rule_lines: None,
69646943 definitions: BTreeMap::new(),
......@@ -7060,6 +7039,7 @@ mod tests {
70607039 identity: None,
70617040 created: None,
70627041 margin_origin: [36.0, 14.4],
7042 rtl: false,
70637043 color: None,
70647044 rule_lines: None,
70657045 definitions: BTreeMap::new(),
......@@ -10623,4 +10603,34 @@ mod tests {
1062310603 [[(0, 0), (0, 5)]; 6]
1062410604 );
1062510605 }
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 }
1062610636}
crates/canvas/src/editor/equation.rs+2-10
......@@ -186,7 +186,7 @@ impl CanvasEditor {
186186 }
187187
188188 /// 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 {
190190 self.caret_zone().is_some()
191191 }
192192
......@@ -270,15 +270,7 @@ impl CanvasEditor {
270270 }
271271 Ok(())
272272 })();
273 let entries = self.undo.split_off(depth);
274 match entries.len() {
275 0 => {}
276 1 => self.undo.extend(entries),
277 _ => self.undo.push(History::Group {
278 entries,
279 page: false,
280 }),
281 }
273 self.group(depth, false);
282274 result
283275 }
284276
crates/canvas/src/editor/format.rs+63-89
......@@ -208,8 +208,8 @@ impl DefaultFont {
208208 subscript: Some(false),
209209 font: Some(self.face.clone()),
210210 font_size: Some(self.size),
211 color: Some(self.color.unwrap_or(0xff00_0000)),
212 highlight: Some(0xff00_0000),
211 color: Some(self.color.unwrap_or(AUTOMATIC)),
212 highlight: Some(AUTOMATIC),
213213 space_before: Some(0.0),
214214 space_after: Some(0.0),
215215 line_spacing: Some(0.0),
......@@ -293,7 +293,7 @@ impl NoteTag {
293293 }
294294
295295 /// 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 {
297297 Definition {
298298 kind: Kind::TagDefinition {
299299 label: Some(self.label.clone()),
......@@ -453,18 +453,18 @@ fn covered(
453453 })
454454}
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();
457458 for node in nodes {
458 match &mut node.content {
459 ParagraphContent::Text(_) => change(node),
460 ParagraphContent::Table(table) => {
461 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
462 leaves_mut(&mut cell.paragraphs, change);
463 }
459 if node.text().is_some() {
460 leaves.push(node);
461 } else if let ParagraphContent::Table(table) = &mut node.content {
462 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
463 leaves.extend(leaves_mut(&mut cell.paragraphs));
464464 }
465 _ => {}
466465 }
467466 }
467 leaves
468468}
469469
470470/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.
......@@ -753,6 +753,7 @@ impl CanvasEditor {
753753 | Formatting::Tag(..)
754754 | Formatting::RemoveTags
755755 | Formatting::Check
756 | Formatting::Style(_)
756757 if title =>
757758 {
758759 return Ok(());
......@@ -763,7 +764,7 @@ impl CanvasEditor {
763764 }
764765 Formatting::Align(alignment) => {
765766 let bases = bases(&replacement)?;
766 leaves_mut(&mut replacement, &mut |node| {
767 for node in leaves_mut(&mut replacement) {
767768 if ranges.remove(&node.id).is_some() {
768769 let value = match alignment {
769770 Alignment::Left => bases[&node.id].alignment.map(|_| 0),
......@@ -775,7 +776,7 @@ impl CanvasEditor {
775776 format.alignment = value;
776777 });
777778 }
778 });
779 }
779780 }
780781 Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => {
781782 let style = match command {
......@@ -821,7 +822,7 @@ impl CanvasEditor {
821822 self.definitions.insert(id, definition);
822823 lists.insert(node, id);
823824 }
824 leaves_mut(&mut replacement, &mut |node| {
825 for node in leaves_mut(&mut replacement) {
825826 if ranges.remove(&node.id).is_some() {
826827 if remove {
827828 node.lists.clear();
......@@ -829,50 +830,40 @@ impl CanvasEditor {
829830 node.lists = vec![*id];
830831 }
831832 }
832 });
833 }
833834 }
834835 Formatting::Tag(tag, action_type) => {
835836 let definition = tag.definition(*action_type);
836 let existing = self
837 .definitions
838 .iter()
839 .find(|(_, other)| other.kind == definition.kind);
840 let id = match existing {
841 Some((id, _)) => *id,
842 None => {
843 let id = new_id()?;
844 self.definitions.insert(id, definition.clone());
845 id
846 }
847 };
837 let id = self.define_tag(&definition)?;
848838 let has = |node: &PageParagraph| {
849839 tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind))
850840 };
851841 let remove = covered(&replacement, ends).all(|(node, _)| has(node));
852842 let created = time32();
853 leaves_mut(&mut replacement, &mut |node| {
843 for node in leaves_mut(&mut replacement) {
854844 if ranges.remove(&node.id).is_none() || !remove && has(node) {
855 return;
845 continue;
856846 }
857847 let ParagraphContent::Text(text) = &mut node.content else {
858848 unreachable!()
859849 };
860850 let added = (!remove).then_some((id, tag.shape, created));
861851 self.retag([&mut node.tags, &mut text.tags], &definition.kind, added);
862 });
852 }
863853 }
864 Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {
865 if ranges.remove(&node.id).is_some() {
866 node.tags.clear();
867 node.text_mut().unwrap().tags.clear();
854 Formatting::RemoveTags => {
855 for node in leaves_mut(&mut replacement) {
856 if ranges.remove(&node.id).is_some() {
857 node.tags.clear();
858 node.text_mut().unwrap().tags.clear();
859 }
868860 }
869 }),
870 Formatting::Check => self.check(&mut replacement, ranges, ends),
871 Formatting::Style(_) if title => return Ok(()),
861 }
862 Formatting::Check => self.check(&mut replacement, &ranges),
872863 Formatting::Style(definition) => {
873864 let olds = bases(&replacement)?;
874865 let style = self.define_style(definition)?;
875 leaves_mut(&mut replacement, &mut |node| {
866 for node in leaves_mut(&mut replacement) {
876867 if ranges.remove(&node.id).is_some() {
877868 node.style = Some(style);
878869 let old = &olds[&node.id];
......@@ -899,7 +890,7 @@ impl CanvasEditor {
899890 };
900891 });
901892 }
902 });
893 }
903894 }
904895 Formatting::Toggle(_)
905896 | Formatting::Font(_)
......@@ -971,7 +962,7 @@ impl CanvasEditor {
971962 self.pending = Some((id, caret, format));
972963 return Ok(());
973964 }
974 leaves_mut(&mut replacement, &mut |node| {
965 for node in leaves_mut(&mut replacement) {
975966 if let Some(range) = ranges.remove(&node.id) {
976967 let base = &bases[&node.id];
977968 let text = &mut node.text_mut().unwrap().text;
......@@ -984,7 +975,7 @@ impl CanvasEditor {
984975 });
985976 }
986977 }
987 });
978 }
988979 }
989980 }
990981 if self.active_outline().document.container(container)?[range.clone()] == replacement[..] {
......@@ -1018,6 +1009,20 @@ impl CanvasEditor {
10181009 Ok(id)
10191010 }
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
10211026 /// A paragraph style named `name`: the page's in its theme's formatting, else the page's
10221027 /// first of that name, else the theme's made on the page; Normal falls back to the
10231028 /// Default font's. None where nothing names it.
......@@ -1100,7 +1105,7 @@ impl CanvasEditor {
11001105 let ranges = covered(&replacement, ends)
11011106 .map(|(node, range)| (node.id, range))
11021107 .collect();
1103 self.check(&mut replacement, ranges, ends);
1108 self.check(&mut replacement, &ranges);
11041109 let selection = outline.selection;
11051110 self.commit(
11061111 engine,
......@@ -1176,19 +1181,7 @@ impl CanvasEditor {
11761181 match command {
11771182 Formatting::Tag(tag, action_type) => {
11781183 let definition = tag.definition(*action_type);
1179 let existing = self
1180 .definitions
1181 .iter()
1182 .find(|(_, other)| other.kind == definition.kind)
1183 .map(|(id, _)| *id);
1184 let defined = match existing {
1185 Some(id) => id,
1186 None => {
1187 let id = new_id()?;
1188 self.definitions.insert(id, definition.clone());
1189 id
1190 }
1191 };
1184 let defined = self.define_tag(&definition)?;
11921185 let has = tags
11931186 .iter()
11941187 .any(|tag| self.tag_kind(tag) == Some(&definition.kind));
......@@ -1284,7 +1277,8 @@ impl CanvasEditor {
12841277 );
12851278 }
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.
12881282 fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {
12891283 let checkable = |tag: &Tag| {
12901284 matches!(
......@@ -1307,39 +1301,19 @@ impl CanvasEditor {
13071301 }
13081302
13091303 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
1310 /// checked; OneNote keeps a cleared box's completion time as zero.
1311 fn check(
1312 &self,
1313 replacement: &mut [PageParagraph],
1314 mut covered: BTreeMap<ExGuid, Range<usize>>,
1315 ends: Ends,
1316 ) {
1317 let checkable = |tag: &Tag| {
1318 matches!(
1319 self.tag_kind(tag),
1320 Some(Kind::TagDefinition { shape: Some(shape), .. })
1321 if crate::outline::checkable(*shape)
1322 )
1323 };
1324 let checked = self::covered(replacement, ends)
1325 .flat_map(|(node, _)| tags(node))
1326 .filter(|tag| checkable(tag))
1327 .all(|tag| tag.status & 1 != 0);
1328 let completed = if checked { Some(0) } else { time32() };
1329 leaves_mut(replacement, &mut |node| {
1330 if covered.remove(&node.id).is_none() {
1331 return;
1332 }
1333 let ParagraphContent::Text(text) = &mut node.content else {
1334 unreachable!()
1335 };
1336 for tag in node.tags.iter_mut().chain(&mut text.tags) {
1337 if checkable(tag) && (tag.status & 1 != 0) == checked {
1338 tag.status ^= 1;
1339 tag.completed = completed;
1340 }
1341 }
1342 });
1304 /// checked.
1305 fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap<ExGuid, Range<usize>>) {
1306 let tags = leaves_mut(replacement)
1307 .into_iter()
1308 .filter(|node| covered.contains_key(&node.id))
1309 .flat_map(|node| {
1310 let ParagraphContent::Text(text) = &mut node.content else {
1311 unreachable!()
1312 };
1313 node.tags.iter_mut().chain(&mut text.tags)
1314 })
1315 .collect();
1316 self.toggle_checks(tags);
13431317 }
13441318}
13451319
crates/canvas/src/editor/ink.rs+30-48
......@@ -35,7 +35,7 @@ impl Pen {
3535 }
3636 }
3737
38 pub const fn highlighter(color: u32) -> Self {
38 pub(crate) const fn highlighter(color: u32) -> Self {
3939 Self {
4040 width: 70.0,
4141 color: Some(color),
......@@ -130,25 +130,8 @@ impl CanvasEditor {
130130 let page::Content::Ink(ink) = &self.objects[index] else {
131131 continue;
132132 };
133 let offset = page::ink_offset(ink);
134 let touched = |stroke: &InkStroke| {
135 let reach = reach + stroke.width.max(stroke.height) / 2.0;
136 let mut points = stroke
137 .points
138 .iter()
139 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
140 let Some(mut previous) = points.next() else {
141 return false;
142 };
143 if segments_distance([previous, previous], [from, to]) <= reach {
144 return true;
145 }
146 points.any(|point| {
147 let near = segments_distance([previous, point], [from, to]) <= reach;
148 previous = point;
149 near
150 })
151 };
133 let offset = crate::origin(&ink.layout);
134 let touched = |stroke: &InkStroke| stroke_near(stroke, offset, [from, to], reach);
152135 let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink
153136 .strokes
154137 .iter()
......@@ -216,13 +199,13 @@ impl CanvasEditor {
216199
217200 /// The page's drawings, on top first, with most of their points inside `lasso`, a
218201 /// 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> {
220203 self.objects
221204 .iter()
222205 .rev()
223206 .filter_map(|object| match object {
224207 page::Content::Ink(ink) => {
225 let offset = page::ink_offset(ink);
208 let offset = crate::origin(&ink.layout);
226209 let points = points(ink);
227210 let inside = points
228211 .iter()
......@@ -236,28 +219,11 @@ impl CanvasEditor {
236219 }
237220
238221 /// 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> {
240223 self.objects.iter().rev().find_map(|object| match object {
241224 page::Content::Ink(ink) => {
242 let offset = page::ink_offset(ink);
243 let near = |stroke: &InkStroke| {
244 let reach = reach + stroke.width.max(stroke.height) / 2.0;
245 let mut points = stroke
246 .points
247 .iter()
248 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
249 let first = points.next();
250 first.is_some_and(|first| {
251 let mut previous = first;
252 segments_distance([first, first], [point, point]) <= reach
253 || points.any(|p| {
254 let near =
255 segments_distance([previous, p], [point, point]) <= reach;
256 previous = p;
257 near
258 })
259 })
260 };
225 let offset = crate::origin(&ink.layout);
226 let near = |stroke: &InkStroke| stroke_near(stroke, offset, [point, point], reach);
261227 (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near)))
262228 .then_some(ink.id)
263229 }
......@@ -271,11 +237,8 @@ impl CanvasEditor {
271237 self.objects
272238 .iter()
273239 .filter_map(|object| match object {
274 page::Content::Ink(ink) if ids.contains(&ink.id) => {
275 let [dx, dy] = self.ink_drag_offset(ink.id);
276 page::ink_bounds(ink)
277 .map(|[x0, y0, x1, y1]| [x0 + dx, y0 + dy, x1 + dx, y1 + dy])
278 }
240 page::Content::Ink(ink) if ids.contains(&ink.id) => page::ink_bounds(ink)
241 .map(|bounds| crate::translated(bounds, self.ink_drag_offset(ink.id))),
279242 _ => None,
280243 })
281244 .reduce(|a, b| {
......@@ -290,7 +253,7 @@ impl CanvasEditor {
290253
291254 /// Shows drawings dragged `delta` from where they lie, without storing it, until the
292255 /// 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])>) {
294257 self.ink_drag = drag;
295258 }
296259
......@@ -427,6 +390,25 @@ impl CanvasEditor {
427390 }
428391}
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
430412fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool {
431413 ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched))
432414}
crates/canvas/src/editor/link.rs+15-20
......@@ -3,18 +3,18 @@
33//! typed and ended by a space or Enter becomes a link of its own text, typing after a label
44//! is plain text, and Remove Link leaves the label as plain text.
55
6use super::format::{leaves_mut, restyle, selected};
6use super::format::restyle;
77use super::*;
88
99/// A link in a paragraph, in UTF-16 offsets of its stored text.
1010#[derive(Clone, Debug, PartialEq, Eq)]
1111pub struct Link {
12 pub paragraph: usize,
12 pub(crate) paragraph: usize,
1313 /// The hidden field code naming the address, before the label; none where the label is
1414 /// its own address.
15 pub code: Option<Range<u32>>,
15 pub(crate) code: Option<Range<u32>>,
1616 pub label: Range<u32>,
17 pub target: String,
17 pub(crate) target: String,
1818}
1919
2020impl Link {
......@@ -193,7 +193,7 @@ impl CanvasEditor {
193193
194194 /// The address of the link drawn under document point `point` of outline `id`, which a
195195 /// 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> {
197197 let outline = self.visible_outlines().find(|outline| outline.id == id)?;
198198 let index = outline.paragraph_at(x, y).ok()?;
199199 let paragraph = &outline.shaped.paragraphs[index];
......@@ -414,7 +414,7 @@ impl CanvasEditor {
414414 }
415415
416416 /// 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> {
418418 let [anchor, focus] = self.active_outline().selection.positions;
419419 let Some(link) = self.link_at(anchor.min(focus)) else {
420420 return Ok(false);
......@@ -491,25 +491,20 @@ impl CanvasEditor {
491491 text: Paragraph,
492492 selection: Selection,
493493 ) -> Result<(), EditorError> {
494 let document = &self.active_outline().document;
495 let at = TextPosition {
496 paragraph,
497 offset: 0,
498 };
499 let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?;
500 let mut replacement = document.container(container)?[range.clone()].to_vec();
501 leaves_mut(&mut replacement, &mut |node| {
502 if node.id == id {
503 node.text_mut().unwrap().text = text.clone();
504 }
505 });
494 let (container, index, node) = self
495 .active_outline()
496 .document
497 .leaf(paragraph)
498 .ok_or(EditError::InvalidRange)?;
499 let mut node = node.clone();
500 node.text_mut().unwrap().text = text;
506501 self.commit(
507502 engine,
508503 DocumentEdit {
509504 columns: BTreeMap::new(),
510505 container,
511 range,
512 replacement,
506 range: index..index + 1,
507 replacement: vec![node],
513508 },
514509 selection,
515510 )
crates/canvas/src/editor/ops.rs+12-19
......@@ -285,7 +285,6 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {
285285 Ok(ops)
286286}
287287
288/// A page picture's stored position, size and description.
289288/// The op giving `target` `tags`, with the definitions they name.
290289pub(super) fn tags(
291290 target: ExGuid,
......@@ -306,6 +305,7 @@ pub(super) fn tags(
306305 }]
307306}
308307
308/// A page picture's stored position, size and description.
309309pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {
310310 vec![PageOp::Picture {
311311 picture: image.id,
......@@ -404,25 +404,17 @@ impl CanvasEditor {
404404 return layout_ops(outline.id, &old, &moved(&outline.layout));
405405 }
406406 for object in &self.objects {
407 match object {
408 page::Content::Image(image) if image.id == placement.id => return picture(image),
409 page::Content::Ink(ink) if ink.id == placement.id => {
410 return layout_ops(ink.id, &old, &moved(&ink.layout));
411 }
412 page::Content::File { source, .. } if source.id == placement.id => {
413 return layout_ops(source.id, &old, &moved(&source.layout));
414 }
415 page::Content::Outline { source, .. } if source.id == placement.id => {
416 return layout_ops(source.id, &old, &moved(&source.layout));
407 if let Some(image) = object.picture().filter(|image| image.id == placement.id) {
408 return picture(image);
409 }
410 match (object, object.layout()) {
411 (page::Content::ReadOnly(object), Some((id, _)))
412 if id == placement.id && !matches!(object.source, PageObject::Outline(_)) =>
413 {
414 return Err(refused("Unsupported objects cannot be moved"));
417415 }
418 page::Content::ReadOnly(object) if object.source.id() == placement.id => {
419 return match &object.source {
420 PageObject::Outline(source) => {
421 layout_ops(source.id, &old, &moved(&source.layout))
422 }
423 PageObject::Image(image) => picture(image),
424 _ => Err(refused("Unsupported objects cannot be moved")),
425 };
416 (_, Some((id, layout))) if id == placement.id => {
417 return layout_ops(id, &old, &moved(layout));
426418 }
427419 _ => {}
428420 }
......@@ -467,6 +459,7 @@ impl CanvasEditor {
467459 identity: None,
468460 created: None,
469461 margin_origin: [0.0; 2],
462 rtl: false,
470463 color: None,
471464 rule_lines: None,
472465 objects,
crates/canvas/src/editor/page.rs+25-40
......@@ -10,19 +10,19 @@ use std::collections::BTreeMap;
1010
1111/// A title object's own state, plus the child origins `build` replaces with page coordinates.
1212pub(crate) struct TitleArea {
13 pub id: onestore::ExGuid,
14 pub date: Option<onestore::ExGuid>,
15 pub layout: onestore::document::Layout,
16 pub origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>,
13 pub(crate) id: onestore::ExGuid,
14 pub(crate) date: Option<onestore::ExGuid>,
15 pub(crate) layout: onestore::document::Layout,
16 pub(crate) origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>,
1717}
1818
1919pub(crate) struct Import {
20 pub objects: Vec<Content>,
21 pub outlines: Vec<TextOutline>,
22 pub date: Option<PageDate>,
23 pub areas: Vec<TitleArea>,
20 pub(crate) objects: Vec<Content>,
21 pub(crate) outlines: Vec<TextOutline>,
22 pub(crate) date: Option<PageDate>,
23 pub(crate) areas: Vec<TitleArea>,
2424 /// 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>,
2626}
2727
2828pub(crate) enum Content {
......@@ -95,9 +95,8 @@ impl Content {
9595 let Self::File { source, layout } = self else {
9696 return None;
9797 };
98 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
99 let [x0, y0, x1, y1] = layout.bounds();
100 Some((source, [x + x0, y + y0, x + x1, y + y1]))
98 let origin = crate::origin(&source.layout);
99 Some((source, crate::translated(layout.bounds(), origin)))
101100 }
102101
103102 /// A tagged picture or file: its identity, note tags and bounds in page points.
......@@ -106,7 +105,7 @@ impl Content {
106105 ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> {
107106 let (id, tags, bounds) = match self {
108107 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);
110109 let [width, height] = crate::outline::image_size(image)?;
111110 (image.id, &image.tags, [x, y, x + width, y + height])
112111 }
......@@ -143,14 +142,11 @@ impl ReadOnlyObject {
143142 [0.0; 2]
144143 };
145144 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];
148145 let width = layout.max_width.unwrap_or(160.0);
149146 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) {
151148 return Err(EditorError::InvalidGeometry);
152149 }
153 let width = width.max(160.0);
154150 let label = engine.layout(
155151 &Paragraph::new(
156152 message.into(),
......@@ -160,21 +156,21 @@ impl ReadOnlyObject {
160156 ..Default::default()
161157 },
162158 ),
163 width - 16.0,
159 width.max(160.0) - 16.0,
164160 )?;
165 let height = height.max(label.height() + 16.0);
166 let rect = [x, y, x + width, y + height];
167 if rect.iter().any(|v| !v.is_finite()) {
168 return Err(EditorError::InvalidGeometry);
169 }
170 Ok(Box::new(Self {
161 let object = Box::new(Self {
171162 source,
172163 message,
173164 label,
174165 offset,
175 }))
166 });
167 if object.rect().iter().any(|v| !v.is_finite()) {
168 return Err(EditorError::InvalidGeometry);
169 }
170 Ok(object)
176171 }
177 pub fn rect(&self) -> [f32; 4] {
172
173 pub(crate) fn rect(&self) -> [f32; 4] {
178174 let layout = self.source.layout();
179175 let x = layout.x.unwrap_or(0.0) + self.offset[0];
180176 let y = layout.y.unwrap_or(0.0) + self.offset[1];
......@@ -206,10 +202,7 @@ pub(crate) fn build(
206202 for object in std::mem::take(&mut page.objects) {
207203 match &object {
208204 PageObject::Outline(outline) => {
209 let origin = [
210 outline.layout.x.unwrap_or(0.0),
211 outline.layout.y.unwrap_or(0.0),
212 ];
205 let origin = crate::origin(&outline.layout);
213206 if origin.iter().any(|v| !v.is_finite()) {
214207 return Err(EditorError::InvalidGeometry);
215208 }
......@@ -377,10 +370,7 @@ pub(crate) fn build(
377370 objects.push(Content::unavailable(object, engine)?);
378371 continue;
379372 };
380 let origin = [
381 source.layout.x.unwrap_or(0.0),
382 source.layout.y.unwrap_or(0.0),
383 ];
373 let origin = crate::origin(&source.layout);
384374 if origin.iter().any(|v| !v.is_finite())
385375 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)
386376 || !(origin[0] + size[0]).is_finite()
......@@ -435,12 +425,7 @@ pub(crate) fn build(
435425/// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page
436426/// points.
437427pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {
438 let [x, y] = ink_offset(ink);
439 stroke_bounds(ink).map(|[x0, y0, x1, y1]| [x0 + x, y0 + y, x1 + x, y1 + y])
440}
441
442pub(crate) fn ink_offset(ink: &Ink) -> [f32; 2] {
443 [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)]
428 stroke_bounds(ink).map(|bounds| crate::translated(bounds, crate::origin(&ink.layout)))
444429}
445430
446431/// 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];
259259
260260impl CanvasEditor {
261261 /// 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> {
263263 let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id);
264264 self.objects
265265 .iter()
......@@ -293,7 +293,7 @@ impl CanvasEditor {
293293 /// The play button OneNote shows for document point `point`: beside the linked note or
294294 /// the recording level with it, over its outline or in the margin before it. Gives the
295295 /// 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)> {
297297 let [x, y] = point;
298298 let outline = self.visible_outlines().find(|outline| {
299299 let bounds = outline.bounds();
crates/canvas/src/editor/space.rs+6-17
......@@ -68,7 +68,7 @@ impl CanvasEditor {
6868 layout,
6969 below_title: None,
7070 } => {
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);
7272 (source.id, [x, y, x + layout.size[0], y + layout.size[1]])
7373 }
7474 page::Content::Image(image) => {
......@@ -109,13 +109,13 @@ impl CanvasEditor {
109109
110110 /// How far back from `line` Insert Space can take the page along `axis`: never over what
111111 /// 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 {
113113 (self.spaced(axis, line).1 - line).min(0.0)
114114 }
115115
116116 /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along
117117 /// `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]> {
119119 self.spaced(axis, line)
120120 .0
121121 .into_iter()
......@@ -156,7 +156,7 @@ impl CanvasEditor {
156156 let layout = self.object_layout_mut(id).unwrap();
157157 // Undo restores an unset coordinate as the zero it stands for, as ops
158158 // cannot unset one.
159 let origin = [layout.x, layout.y].map(|v| v.unwrap_or(0.0));
159 let origin = crate::origin(layout);
160160 let previous = origin.map(Some);
161161 [layout.x, layout.y] = shift(origin).map(Some);
162162 self.undo.push(History::Position {
......@@ -182,27 +182,16 @@ impl CanvasEditor {
182182 }
183183 };
184184 if let Err(error) = result {
185 self.group(depth);
185 self.group(depth, false);
186186 return Err(error);
187187 }
188188 }
189 self.group(depth);
189 self.group(depth, false);
190190 self.redo.clear();
191191 self.preferred_x = None;
192192 Ok(true)
193193 }
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
206195 /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which
207196 /// takes the rest of the outline's layout; returns each root `part` then holds with the
208197 /// parent it had.
crates/canvas/src/editor/table.rs+83-113
......@@ -65,16 +65,59 @@ fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
6565 Default::default(),
6666 )?,
6767 };
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> {
6877 Ok(TableCell {
6978 id: new_id()?,
7079 layout: Default::default(),
71 indents: source.indents.clone(),
72 shading: source.shading,
73 paragraphs: vec![node],
80 indents,
81 shading,
82 paragraphs,
7483 unsupported: Vec::new(),
7584 })
7685}
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
78121fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> {
79122 let mut content = leaves(&cell.paragraphs, None);
80123 let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?;
......@@ -250,7 +293,11 @@ impl CanvasEditor {
250293impl TextOutline {
251294 /// The table column whose right border lies within `reach` of outline-local `point`, as
252295 /// `(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)> {
254301 let columns = descendants(self.document.nodes(), None)
255302 .filter_map(|(_, _, node)| match &node.content {
256303 ParagraphContent::Table(table) => Some((table.id, table.columns.len())),
......@@ -276,7 +323,7 @@ impl TextOutline {
276323impl CanvasEditor {
277324 /// The active outline with column `column` of `table` `width` wide, as a border drag
278325 /// shows it before release.
279 pub fn preview_column(
326 pub(crate) fn preview_column(
280327 &self,
281328 engine: &mut TextEngine,
282329 table: ExGuid,
......@@ -383,53 +430,23 @@ impl CanvasEditor {
383430 let text = &node.text().unwrap().text;
384431 let end = text.utf16_offset(text.text().len())?;
385432 let format = text.format_at(caret.offset)?;
386 let cell = || -> Result<TableCell, EditError> {
433 let empty = || -> Result<TableCell, EditError> {
387434 let mut paragraph = crate::document::node(
388435 Paragraph::new(String::new(), format.clone()),
389436 node.format.clone(),
390437 )?;
391438 paragraph.style = node.style;
392 Ok(TableCell {
393 id: new_id()?,
394 layout: Default::default(),
395 indents: outline.indents.clone(),
396 shading: None,
397 paragraphs: vec![paragraph],
398 unsupported: Vec::new(),
399 })
400 };
401 let table = PageParagraph {
402 id: new_id()?,
403 parent: node.parent,
404 level: node.level,
405 style: None,
406 format: node.format.clone(),
407 lists: Vec::new(),
408 tags: Vec::new(),
409 media: Default::default(),
410 collapsed: false,
411 content: ParagraphContent::Table(onestore::page::Table {
412 id: new_id()?,
413 columns: vec![
414 TableColumn {
415 width: COLUMN_WIDTH,
416 locked: false
417 };
418 columns
419 ],
420 rows: (0..rows)
421 .map(|_| {
422 Ok(TableRow {
423 id: new_id()?,
424 cells: (0..columns).map(|_| cell()).collect::<Result<_, _>>()?,
425 })
426 })
427 .collect::<Result<_, EditError>>()?,
428 borders: Some(true),
429 layout: Default::default(),
430 tags: Vec::new(),
431 }),
439 new_cell(outline.indents.clone(), None, vec![paragraph])
432440 };
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)?;
433450 let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id);
434451 let childless = !outline.document.container(container)?.iter().any(parent);
435452 let (edit, first) = if end == 0 && childless {
......@@ -489,12 +506,8 @@ impl CanvasEditor {
489506 let format = source.text().unwrap().text.format_at(focus.offset)?;
490507 let mut split = outline.document.replace(
491508 focus..focus,
492 vec![
493 Paragraph::new(String::new(), format.clone()),
494 Paragraph::new(String::new(), format.clone()),
495 ],
509 vec![Paragraph::new(String::new(), format.clone()); 2],
496510 )?;
497 let id = new_id()?;
498511 // The table's paragraph holds the list, tags and children (`evidence/structural-edits/
499512 // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`).
500513 let children = split.replacement.split_off(2);
......@@ -511,44 +524,20 @@ impl CanvasEditor {
511524 paragraph.level = 1;
512525 paragraph.parent = None;
513526 paragraph.lists.clear();
514 Ok(TableCell {
515 id: new_id()?,
516 layout: Default::default(),
517 indents: outline.indents.clone(),
518 shading: None,
519 paragraphs: vec![paragraph],
520 unsupported: Vec::new(),
521 })
527 new_cell(outline.indents.clone(), None, vec![paragraph])
522528 })
523529 .collect::<Result<Vec<_>, EditError>>()?;
530 let row = TableRow {
531 id: new_id()?,
532 cells,
533 };
524534 let wrapper = PageParagraph {
525 id,
526 parent: source.parent,
527 level: source.level,
528 style: None,
529 format: source.format.clone(),
530535 lists,
531536 tags,
532 media: Default::default(),
533537 collapsed,
534 content: ParagraphContent::Table(onestore::page::Table {
535 id: new_id()?,
536 columns: vec![
537 TableColumn {
538 width: COLUMN_WIDTH,
539 locked: false
540 };
541 2
542 ],
543 rows: vec![TableRow {
544 id: new_id()?,
545 cells,
546 }],
547 borders: Some(true),
548 layout: Default::default(),
549 tags: Vec::new(),
550 }),
538 ..table_paragraph(source, 2, vec![row])?
551539 };
540 let id = wrapper.id;
552541 split.replacement.push(wrapper);
553542 split
554543 .replacement
......@@ -598,22 +587,12 @@ impl CanvasEditor {
598587 let format = source.text().unwrap().text.format_at(focus.offset)?;
599588 let split = outline.document.replace(
600589 focus..focus,
601 vec![
602 Paragraph::new(String::new(), format.clone()),
603 Paragraph::new(String::new(), format.clone()),
604 ],
590 vec![Paragraph::new(String::new(), format.clone()); 2],
605591 )?;
606 let cell = &mut table.rows[0].cells[location.column];
607 cell.paragraphs.splice(split.range, split.replacement);
608 let paragraphs = cell.paragraphs.split_off(local + 1);
609 let target = TableCell {
610 id: new_id()?,
611 layout: Default::default(),
612 indents: cell.indents.clone(),
613 shading: cell.shading,
614 paragraphs,
615 unsupported: Vec::new(),
616 };
592 let source = &mut table.rows[0].cells[location.column];
593 source.paragraphs.splice(split.range, split.replacement);
594 let paragraphs = source.paragraphs.split_off(local + 1);
595 let target = new_cell(source.indents.clone(), source.shading, paragraphs)?;
617596 table.rows[0].cells.push(target);
618597 table.columns.push(TableColumn {
619598 width: COLUMN_WIDTH,
......@@ -722,7 +701,7 @@ impl CanvasEditor {
722701 let ParagraphContent::Table(table) = &mut wrapper.content else {
723702 unreachable!()
724703 };
725 let (replacement, next) = if exit {
704 let replacement = if exit {
726705 table.rows.pop();
727706 let blank = || {
728707 Ok::<_, EditError>(PageParagraph {
......@@ -732,14 +711,7 @@ impl CanvasEditor {
732711 ..self.blank_paragraph(source)?
733712 })
734713 };
735 let (first, second) = (blank()?, blank()?);
736 (
737 vec![wrapper, first, second],
738 TextPosition {
739 paragraph: focus.paragraph + 1,
740 offset: 0,
741 },
742 )
714 vec![wrapper, blank()?, blank()?]
743715 } else {
744716 let cells = row
745717 .cells
......@@ -750,13 +722,11 @@ impl CanvasEditor {
750722 id: new_id()?,
751723 cells,
752724 });
753 (
754 vec![wrapper],
755 TextPosition {
756 paragraph: focus.paragraph + 1,
757 offset: 0,
758 },
759 )
725 vec![wrapper]
726 };
727 let next = TextPosition {
728 paragraph: focus.paragraph + 1,
729 offset: 0,
760730 };
761731 self.commit(
762732 engine,
crates/canvas/src/gpu/page.rs+50-105
......@@ -301,7 +301,7 @@ impl PageScene {
301301
302302 /// Follows `editor` after an edit or undo brought page-level pictures the scene has not
303303 /// seen, as `refresh` does.
304 pub fn follow(
304 pub(crate) fn follow(
305305 &mut self,
306306 editor: &mut CanvasEditor,
307307 engine: &mut TextEngine,
......@@ -415,7 +415,7 @@ impl PageScene {
415415 /// this thread; `waker` is woken when one lands and the page should be drawn again.
416416 /// Call before collecting each frame's primitives. True once everything in view shows
417417 /// its raster for this paper and scale.
418 pub fn update_pictures(
418 pub(crate) fn update_pictures(
419419 &mut self,
420420 editor: Option<&CanvasEditor>,
421421 view: [f32; 4],
......@@ -468,37 +468,32 @@ impl PageScene {
468468 [x, y]: [f32; 2],
469469 rects: &mut Vec<(onestore::ExGuid, [f32; 4])>,
470470 ) {
471 rects.extend(layout.objects.iter().map(|object| {
472 let [x0, y0, x1, y1] = object.rect;
473 (object.id, [x0 + x, y0 + y, x1 + x, y1 + y])
474 }));
471 rects.extend(
472 layout
473 .objects
474 .iter()
475 .map(|object| (object.id, crate::translated(object.rect, [x, y]))),
476 );
475477 }
476478 let mut rects = Vec::new();
477479 for content in self.objects(editor).into_iter().flatten() {
478480 match content {
479481 Content::Image(source) => {
480 let [x, y] = [
481 source.layout.x.unwrap_or(0.0),
482 source.layout.y.unwrap_or(0.0),
483 ];
482 let [x, y] = crate::origin(&source.layout);
484483 if let Some([width, height]) = crate::outline::image_size(source) {
485484 rects.push((source.id, [x, y, x + width, y + height]));
486485 }
487486 }
488487 Content::File { source, layout } => {
489 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
490 let [x0, y0, x1, y1] = layout.rect;
491 rects.push((source.id, [x + x0, y + y0, x + x1, y + y1]));
488 let origin = crate::origin(&source.layout);
489 rects.push((source.id, crate::translated(layout.rect, origin)));
492490 }
493491 Content::Outline {
494492 source,
495493 layout,
496494 below_title,
497495 } => {
498 let origin = [
499 source.layout.x.unwrap_or(0.0),
500 source.layout.y.unwrap_or(0.0),
501 ];
496 let origin = crate::origin(&source.layout);
502497 if let Ok(origin) = outline_origin(origin, *below_title, editor) {
503498 outline(layout, origin, &mut rects);
504499 }
......@@ -518,7 +513,7 @@ impl PageScene {
518513
519514 /// Pictures, files and handwriting inside an outline whose origin is `origin`.
520515 /// `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>(
522517 &'a self,
523518 outline: &'a crate::outline::OutlineLayout,
524519 origin: [f32; 2],
......@@ -541,15 +536,9 @@ impl PageScene {
541536 paper: Paper,
542537 primitives: &mut Vec<Primitive<'a>>,
543538 ) {
544 let [x0, y0, x1, y1] = object.rect;
545539 let rect = match moving {
546540 Some((id, rect)) if id == object.id => rect,
547 _ => [
548 x0 + origin[0],
549 y0 + origin[1],
550 x1 + origin[0],
551 y1 + origin[1],
552 ],
541 _ => crate::translated(object.rect, origin),
553542 };
554543 let picture = self.pictures.get(&object.id);
555544 if let Some(image) = picture.and_then(Picture::image) {
......@@ -644,7 +633,7 @@ impl PageScene {
644633
645634 /// Bounds of noneditable content in page coordinates, each with whether OneNote's view
646635 /// 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>(
648637 &'a self,
649638 editor: &'a CanvasEditor,
650639 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {
......@@ -657,15 +646,9 @@ impl PageScene {
657646 layout,
658647 below_title,
659648 } => {
660 let origin = outline_origin(
661 [
662 source.layout.x.unwrap_or(0.0),
663 source.layout.y.unwrap_or(0.0),
664 ],
665 *below_title,
666 Some(editor),
667 )
668 .ok()?;
649 let origin =
650 outline_origin(crate::origin(&source.layout), *below_title, Some(editor))
651 .ok()?;
669652 Some((
670653 [
671654 origin[0],
......@@ -679,10 +662,7 @@ impl PageScene {
679662 Content::Date { below_title } => {
680663 let date = editor.date()?;
681664 let origin = outline_origin(
682 [
683 date.source().layout.x.unwrap_or(0.0),
684 date.source().layout.y.unwrap_or(0.0),
685 ],
665 crate::origin(&date.source().layout),
686666 *below_title,
687667 Some(editor),
688668 )
......@@ -699,7 +679,7 @@ impl PageScene {
699679 ))
700680 }
701681 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);
703683 let [width, height] = crate::outline::image_size(source)?;
704684 Some(([x, y, x + width, y + height], false))
705685 }
......@@ -710,7 +690,7 @@ impl PageScene {
710690 })
711691 }
712692
713 pub fn date_fields(
693 pub(crate) fn date_fields(
714694 &self,
715695 editor: &CanvasEditor,
716696 ) -> impl Iterator<Item = (DateField, [f32; 4])> {
......@@ -721,13 +701,9 @@ impl PageScene {
721701 .into_iter()
722702 .flatten()
723703 .find_map(|object| match object {
724 Content::Date { below_title } => Some((
725 [
726 date.source().layout.x.unwrap_or(0.0),
727 date.source().layout.y.unwrap_or(0.0),
728 ],
729 *below_title,
730 )),
704 Content::Date { below_title } => {
705 Some((crate::origin(&date.source().layout), *below_title))
706 }
731707 _ => None,
732708 })
733709 && let Ok(origin) = outline_origin(origin, title, Some(editor))
......@@ -794,10 +770,7 @@ impl PageScene {
794770 }
795771 }
796772 Content::Image(source) if !source.background => {
797 let [x, y] = [
798 source.layout.x.unwrap_or(0.0),
799 source.layout.y.unwrap_or(0.0),
800 ];
773 let [x, y] = crate::origin(&source.layout);
801774 if let Some([width, height]) = crate::outline::image_size(source)
802775 && (x..=x + width).contains(&point[0])
803776 && (y..=y + height).contains(&point[1])
......@@ -846,14 +819,7 @@ impl PageScene {
846819 layout,
847820 below_title,
848821 } => {
849 let [x, y] = outline_origin(
850 [
851 source.layout.x.unwrap_or(0.0),
852 source.layout.y.unwrap_or(0.0),
853 ],
854 *below_title,
855 None,
856 )?;
822 let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?;
857823 cover([x, y, x + layout.size[0], y + layout.size[1]], false);
858824 for paragraph in &layout.paragraphs {
859825 let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]];
......@@ -869,10 +835,7 @@ impl PageScene {
869835 }
870836 }
871837 Content::Image(source) => {
872 let [x, y] = [
873 source.layout.x.unwrap_or(0.0),
874 source.layout.y.unwrap_or(0.0),
875 ];
838 let [x, y] = crate::origin(&source.layout);
876839 let size = match self.backgrounds.get(&source.id) {
877840 Some(art) => Some(art.size),
878841 None => crate::outline::image_size(source),
......@@ -912,7 +875,7 @@ impl PageScene {
912875
913876 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
914877 /// 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>>(
916879 &'a self,
917880 primitives: &mut Vec<Primitive<'a>>,
918881 offset: [f32; 2],
......@@ -927,39 +890,16 @@ impl PageScene {
927890 source,
928891 below_title,
929892 ..
930 } => outline_origin(
931 [
932 source.layout.x.unwrap_or(0.0),
933 source.layout.y.unwrap_or(0.0),
934 ],
935 *below_title,
936 editor,
937 )?,
893 } => outline_origin(crate::origin(&source.layout), *below_title, editor)?,
938894 Content::Date { below_title } => {
939895 let date = editor
940896 .and_then(CanvasEditor::date)
941897 .ok_or(SceneError::MissingOutline)?;
942 outline_origin(
943 [
944 date.source().layout.x.unwrap_or(0.0),
945 date.source().layout.y.unwrap_or(0.0),
946 ],
947 *below_title,
948 editor,
949 )?
898 outline_origin(crate::origin(&date.source().layout), *below_title, editor)?
950899 }
951 Content::Image(source) => [
952 source.layout.x.unwrap_or(0.0),
953 source.layout.y.unwrap_or(0.0),
954 ],
900 Content::Image(source) => crate::origin(&source.layout),
955901 Content::ReadOnly(object) => {
956 let [x0, y0, x1, y1] = object.rect();
957 let rect = [
958 x0 + offset[0],
959 y0 + offset[1],
960 x1 + offset[0],
961 y1 + offset[1],
962 ];
902 let rect = crate::translated(object.rect(), offset);
963903 append_placeholder(rect, paper, primitives);
964904 primitives.push(Primitive::Text {
965905 clip: None,
......@@ -974,7 +914,7 @@ impl PageScene {
974914 continue;
975915 }
976916 Content::Ink(ink) => {
977 let [x, y] = crate::editor::page::ink_offset(ink);
917 let [x, y] = crate::origin(&ink.layout);
978918 let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id));
979919 let offset = [offset[0] + x + dx, offset[1] + y + dy];
980920 append_ink(ink, offset, paper.ink, primitives);
......@@ -984,7 +924,7 @@ impl PageScene {
984924 // A dragged file draws where its column would land.
985925 let [x, y] = match moving {
986926 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),
988928 };
989929 self.append_object(
990930 layout,
......@@ -1043,12 +983,7 @@ impl PageScene {
1043983 continue;
1044984 };
1045985 let rect = match moving {
1046 Some((id, [x0, y0, x1, y1])) if id == source.id => [
1047 x0 + offset[0],
1048 y0 + offset[1],
1049 x1 + offset[0],
1050 y1 + offset[1],
1051 ],
986 Some((id, rect)) if id == source.id => crate::translated(rect, offset),
1052987 _ => [
1053988 object_origin[0],
1054989 object_origin[1],
......@@ -1120,7 +1055,7 @@ impl PageScene {
11201055
11211056impl crate::outline::OutlineLayout {
11221057 /// Paragraphs that may paint between outline-local `rows`, with their indices.
1123 pub fn visible(
1058 pub(crate) fn visible(
11241059 &self,
11251060 rows: [f32; 2],
11261061 ) -> impl Iterator<Item = (usize, &crate::outline::ParagraphLayout)> {
......@@ -1141,7 +1076,7 @@ impl crate::outline::OutlineLayout {
11411076 /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black
11421077 /// highlight paints in the paper's ink, censoring the automatic text on it in any theme.
11431078 /// 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(
11451080 &self,
11461081 primitives: &mut Vec<Primitive<'_>>,
11471082 origin: [f32; 2],
......@@ -1200,7 +1135,7 @@ impl crate::outline::OutlineLayout {
12001135
12011136 /// One paragraph of this outline, whose origin is `origin`: its text or equation, list
12021137 /// 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>(
12041139 &'a self,
12051140 index: usize,
12061141 paragraph: &'a crate::outline::ParagraphLayout,
......@@ -1291,7 +1226,7 @@ impl crate::outline::OutlineLayout {
12911226
12921227 /// The note tags of this outline's tables, pictures and files, whose origin is `origin`,
12931228 /// those `art` maps drawn with their art.
1294 pub fn append_block_tag_primitives(
1229 pub(crate) fn append_block_tag_primitives(
12951230 &self,
12961231 primitives: &mut Vec<Primitive<'_>>,
12971232 origin: [f32; 2],
......@@ -1316,7 +1251,7 @@ impl crate::outline::OutlineLayout {
13161251 }
13171252 }
13181253
1319 pub fn append_table_primitives(
1254 pub(crate) fn append_table_primitives(
13201255 &self,
13211256 primitives: &mut Vec<Primitive<'_>>,
13221257 origin: [f32; 2],
......@@ -1409,6 +1344,7 @@ mod tests {
14091344 created: Some(1),
14101345 title: "Header".into(),
14111346 margin_origin: [36.0, 14.4],
1347 rtl: false,
14121348 color: None,
14131349 rule_lines: None,
14141350 definitions: BTreeMap::new(),
......@@ -1858,6 +1794,7 @@ mod tests {
18581794 created: None,
18591795 title: "Header".into(),
18601796 margin_origin: [36.0, 14.4],
1797 rtl: false,
18611798 color: None,
18621799 rule_lines: None,
18631800 definitions: BTreeMap::new(),
......@@ -1934,6 +1871,7 @@ mod tests {
19341871 created: None,
19351872 title: String::new(),
19361873 margin_origin: [0.0; 2],
1874 rtl: false,
19371875 color: None,
19381876 rule_lines: None,
19391877 definitions: BTreeMap::new(),
......@@ -2020,6 +1958,7 @@ mod tests {
20201958 created: None,
20211959 title: String::new(),
20221960 margin_origin: [36.0, 14.0],
1961 rtl: false,
20231962 color: None,
20241963 rule_lines: None,
20251964 objects: vec![unsupported, PageObject::Outline(editable)],
......@@ -2108,6 +2047,7 @@ mod tests {
21082047 created: None,
21092048 title: String::new(),
21102049 margin_origin: [0.0; 2],
2050 rtl: false,
21112051 color: None,
21122052 rule_lines: None,
21132053 definitions: BTreeMap::new(),
......@@ -2294,6 +2234,7 @@ mod tests {
22942234 created: None,
22952235 title: String::new(),
22962236 margin_origin: [0.0; 2],
2237 rtl: false,
22972238 color: None,
22982239 rule_lines: None,
22992240 definitions: BTreeMap::new(),
......@@ -2478,6 +2419,7 @@ mod tests {
24782419 created: None,
24792420 title: String::new(),
24802421 margin_origin: [0.0; 2],
2422 rtl: false,
24812423 color: None,
24822424 rule_lines: None,
24832425 definitions: BTreeMap::new(),
......@@ -2592,6 +2534,7 @@ mod tests {
25922534 created: None,
25932535 title: String::new(),
25942536 margin_origin: [0.0; 2],
2537 rtl: false,
25952538 color: None,
25962539 rule_lines: None,
25972540 definitions: BTreeMap::new(),
......@@ -2689,6 +2632,7 @@ mod tests {
26892632 created: None,
26902633 title: String::new(),
26912634 margin_origin: [0.0; 2],
2635 rtl: false,
26922636 color: None,
26932637 rule_lines: None,
26942638 definitions: BTreeMap::new(),
......@@ -2763,6 +2707,7 @@ mod tests {
27632707 created: None,
27642708 title: String::new(),
27652709 margin_origin: [0.0; 2],
2710 rtl: false,
27662711 color: None,
27672712 rule_lines: None,
27682713 definitions: BTreeMap::new(),
crates/canvas/src/gpu/page/background.rs+8-7
......@@ -410,11 +410,11 @@ const INLINE_PIXELS: f32 = 1024.0 * 1024.0;
410410pub(super) struct Background {
411411 svg: &'static str,
412412 /// Points; OneNote draws a picture without a stored size at its natural size.
413 pub size: [f32; 2],
413 pub(crate) size: [f32; 2],
414414 /// For light and for dark paper.
415415 variants: [Variant; 2],
416416 /// Whether the first raster is made on the frame's thread rather than awaited.
417 pub inline: bool,
417 pub(crate) inline: bool,
418418}
419419
420420/// A raster's device pixels per point, and the dark paper it was recoloured for.
......@@ -430,19 +430,19 @@ struct Variant {
430430
431431impl Background {
432432 /// 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> {
434434 self.variants
435435 .iter()
436436 .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image))
437437 }
438438
439439 /// 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 {
441441 self.svg == other.svg && self.size == other.size
442442 }
443443
444444 /// 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> {
446446 let bytes = picture.bytes.as_deref()?;
447447 let digest: String = Sha256::digest(bytes)
448448 .iter()
......@@ -472,7 +472,7 @@ impl Background {
472472 }
473473
474474 /// 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> {
476476 let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?;
477477 Some(image)
478478 }
......@@ -480,7 +480,7 @@ impl Background {
480480 /// Takes a finished raster and asks the worker for one at `scale` device pixels per
481481 /// point when the shown one differs; `waker` hears when it lands. True once the raster
482482 /// 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 {
484484 let dark = dark(paper);
485485 let [width, height] = self.size.map(|side| side.max(1.0));
486486 let density = density(scale)
......@@ -958,6 +958,7 @@ mod tests {
958958 created: None,
959959 title: String::new(),
960960 margin_origin: [0.0; 2],
961 rtl: false,
961962 color: None,
962963 rule_lines: None,
963964 definitions: BTreeMap::new(),
crates/canvas/src/gpu/page/picture.rs+4-4
......@@ -30,12 +30,12 @@ pub(super) struct Picture {
3030}
3131
3232impl Picture {
33 /// Reads the size from the picture's header; None when the renderer cannot decode it.
3433 /// Whether both show the same stored bytes.
35 pub fn same(&self, other: &Self) -> bool {
34 pub(crate) fn same(&self, other: &Self) -> bool {
3635 self.bytes == other.bytes
3736 }
3837
38 /// Reads the size from the picture's header; None when the renderer cannot decode it.
3939 pub fn new(bytes: &Arc<[u8]>) -> Option<Self> {
4040 Some(Self {
4141 native: RasterImage::measure(bytes).ok()?,
......@@ -48,11 +48,11 @@ impl Picture {
4848 }
4949
5050 /// The latest raster, once one has landed.
51 pub fn image(&self) -> Option<&RasterImage> {
51 pub(crate) fn image(&self) -> Option<&RasterImage> {
5252 self.shown.as_ref()
5353 }
5454
55 pub fn failed(&self) -> bool {
55 pub(crate) fn failed(&self) -> bool {
5656 self.failed
5757 }
5858}
crates/canvas/src/interaction/accessibility.rs+2
......@@ -722,6 +722,7 @@ pub(super) mod tests {
722722 title: "Header".into(),
723723 created: None,
724724 margin_origin: [36.0, 14.4],
725 rtl: false,
725726 color: None,
726727 rule_lines: None,
727728 definitions: Default::default(),
......@@ -816,6 +817,7 @@ pub(super) mod tests {
816817 created: None,
817818 title: String::new(),
818819 margin_origin: [0.0; 2],
820 rtl: false,
819821 color: None,
820822 rule_lines: None,
821823 definitions: Default::default(),
crates/canvas/src/interaction/mod.rs+48-80
......@@ -35,7 +35,7 @@ const HANDLE_HEIGHT: f32 = 6.75;
3535/// Accessible names of the page date's fields.
3636pub 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
4040#[derive(Clone, Copy, Debug, PartialEq)]
4141pub enum Cursor {
......@@ -112,7 +112,7 @@ pub struct Correction {
112112pub struct Response {
113113 pub changed: bool,
114114 pub moved: bool,
115 pub redraw: bool,
115 pub(crate) redraw: bool,
116116 pub request: Option<Request>,
117117}
118118
......@@ -359,7 +359,7 @@ impl PageView {
359359 }
360360
361361 /// 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(
363363 &mut self,
364364 mut editor: CanvasEditor,
365365 scene: Option<(PageScene, [f32; 2])>,
......@@ -435,10 +435,16 @@ impl PageView {
435435 }
436436 }
437437
438 /// OneNote 2010 opens a page scrolled fully left and up, however far down its content
439 /// lies.
438 /// OneNote 2010 opens a page scrolled fully up, however far down its content lies, and
439 /// fully left, or right on a right-to-left page.
440440 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]];
442448 }
443449
444450 pub fn modifiers(&self) -> Modifiers {
......@@ -480,6 +486,12 @@ impl PageView {
480486 self.object_focus.is_none()
481487 }
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
483495 /// The outline being dragged and where it would land.
484496 pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
485497 let Drag::Outline {
......@@ -624,7 +636,7 @@ impl PageView {
624636 /// Page coordinates of a focused object.
625637 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
626638 let (scene, offset) = self.scene.as_ref().unwrap();
627 let [x0, y0, x1, y1] = match focus {
639 let rect = match focus {
628640 ObjectFocus::ReadOnly(index) => scene
629641 .read_only(Some(&self.editor))
630642 .nth(index)
......@@ -636,12 +648,7 @@ impl PageView {
636648 }
637649 ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(),
638650 };
639 [
640 x0 + offset[0],
641 y0 + offset[1],
642 x1 + offset[0],
643 y1 + offset[1],
644 ]
651 crate::translated(rect, *offset)
645652 }
646653
647654 /// Keeps the view in bounds and restarts the caret blink after a change.
......@@ -778,10 +785,7 @@ impl PageView {
778785 return self.moved();
779786 }
780787 }
781 Ok(Response {
782 redraw: true,
783 ..Response::default()
784 })
788 Ok(Response::redraw())
785789 }
786790
787791 /// Moves the caret by lines until it has gone a view's height, then scrolls as far so
......@@ -881,18 +885,16 @@ impl PageView {
881885 .content_bounds(&self.editor)
882886 .map(move |(rect, padded)| {
883887 (
884 [
885 rect[0] + offset[0],
886 rect[1] + offset[1],
887 rect[2] + offset[0],
888 rect[3] + offset[1],
889 ],
888 crate::translated(rect, *offset),
890889 if padded { pad } else { 0.0 },
891890 )
892891 })
893892 });
894 let mut scroll =
895 scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed));
893 let mut scroll = scroll::Scroll::new(
894 self.viewport,
895 editable.map(|rect| (rect, pad)).chain(fixed),
896 self.editor.rtl(),
897 );
896898 if let Some(reach) = self.reach {
897899 for axis in 0..2 {
898900 scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale);
......@@ -989,17 +991,7 @@ impl PageView {
989991 preview,
990992 self.object_focus,
991993 Paint {
992 caret: if self.focused
993 && !matches!(
994 self.drag,
995 Some(
996 Drag::Outline { .. }
997 | Drag::Resize { .. }
998 | Drag::Column { .. }
999 | Drag::Image { .. }
1000 | Drag::Space { .. }
1001 )
1002 ) {
994 caret: if self.focused && matches!(self.drag, None | Some(Drag::Text { .. })) {
1003995 self.caret
1004996 } else {
1005997 0.0
......@@ -1069,7 +1061,6 @@ impl PageView {
10691061 if self.drawing() {
10701062 return Cursor::Crosshair;
10711063 }
1072 let point = self.viewport.document_point(self.pointer);
10731064 if self.drag.is_none() && self.play_button(point).is_some() {
10741065 return Cursor::Pointer;
10751066 }
......@@ -1111,8 +1102,12 @@ impl PageView {
11111102 (repaint, hold.map(|hold| now + hold))
11121103 }
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.
11151107 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 }
11161111 self.viewport.size = size;
11171112 if size.contains(&0) {
11181113 return Ok(Response::default());
......@@ -1534,7 +1529,7 @@ impl PageView {
15341529 {
15351530 return self.edited();
15361531 }
1537 if !self.accepts_text() || self.editor.marked_range().is_some() {
1532 if self.edits_wait() {
15381533 return Ok(Response::default());
15391534 }
15401535 self.editor.format(&mut self.engine, command)?;
......@@ -1783,7 +1778,7 @@ impl PageView {
17831778
17841779 /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`].
17851780 pub fn insert_equation(&mut self) -> Result<Response> {
1786 if !self.accepts_text() || self.editor.marked_range().is_some() {
1781 if self.edits_wait() {
17871782 return Ok(Response::default());
17881783 }
17891784 self.editor.insert_equation(&mut self.engine)?;
......@@ -1792,7 +1787,7 @@ impl PageView {
17921787
17931788 /// Insert, Table: see [`CanvasEditor::insert_table`].
17941789 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() {
17961791 return Ok(Response::default());
17971792 }
17981793 self.editor.insert_table(&mut self.engine, rows, columns)?;
......@@ -1802,7 +1797,7 @@ impl PageView {
18021797 /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see
18031798 /// [`CanvasEditor::insert_picture`].
18041799 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() {
18061801 return Ok(Response::default());
18071802 }
18081803 let image = crate::editor::picture(bytes, size)?;
......@@ -1818,7 +1813,7 @@ impl PageView {
18181813 pieces: Vec<Piece>,
18191814 language: u32,
18201815 ) -> Result<(Vec<Awaited>, Response)> {
1821 if !self.accepts_text() || self.editor.marked_range().is_some() {
1816 if self.edits_wait() {
18221817 return Ok((Vec::new(), Response::default()));
18231818 }
18241819 let awaited = self
......@@ -1845,7 +1840,7 @@ impl PageView {
18451840
18461841 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.
18471842 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() {
18491844 return Ok((None, Response::default()));
18501845 }
18511846 let id = self.editor.start_recording(&mut self.engine, label)?;
......@@ -1879,7 +1874,7 @@ impl PageView {
18791874 /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon
18801875 /// takes the page's blank one.
18811876 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() {
18831878 return Ok(Response::default());
18841879 }
18851880 file.preview
......@@ -2051,17 +2046,7 @@ impl PageView {
20512046 return self.edited();
20522047 }
20532048 }
2054 if matches!(
2055 self.drag,
2056 Some(
2057 Drag::Outline { .. }
2058 | Drag::Resize { .. }
2059 | Drag::Column { .. }
2060 | Drag::Image { .. }
2061 | Drag::Space { .. }
2062 )
2063 ) || self.space
2064 {
2049 if !matches!(self.drag, None | Some(Drag::Text { .. })) || self.space {
20652050 self.drag = None;
20662051 self.space = false;
20672052 if key == &Key::Named(NamedKey::Escape) {
......@@ -2270,7 +2255,7 @@ pub enum Hit {
22702255}
22712256
22722257/// What a document point lands on; `pixel` is document points per device pixel.
2273pub fn page_hit_test(
2258pub(crate) fn page_hit_test(
22742259 editor: &CanvasEditor,
22752260 scene: Option<&(PageScene, [f32; 2])>,
22762261 point: [f32; 2],
......@@ -2574,16 +2559,7 @@ fn page_primitives<'a>(
25742559 if let Some((scene, _)) = scene {
25752560 let moving = match preview {
25762561 Some(PointerFeedback::Image(id, origin, size)) => {
2577 let [x0, y0, x1, y1] = image_rect(origin, size);
2578 Some((
2579 id,
2580 [
2581 x0 + offset[0],
2582 y0 + offset[1],
2583 x1 + offset[0],
2584 y1 + offset[1],
2585 ],
2586 ))
2562 Some((id, crate::translated(image_rect(origin, size), offset)))
25872563 }
25882564 _ => None,
25892565 };
......@@ -2663,13 +2639,10 @@ fn page_primitives<'a>(
26632639 }
26642640 if let Some(ObjectFocus::ReadOnly(index)) = object_focus {
26652641 let (scene, offset) = scene.unwrap();
2666 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
2667 let [x0, y0, x1, y1] = [
2668 x0 + offset[0],
2669 y0 + offset[1],
2670 x1 + offset[0],
2671 y1 + offset[1],
2672 ];
2642 let [x0, y0, x1, y1] = crate::translated(
2643 scene.read_only(Some(editor)).nth(index).unwrap().rect(),
2644 *offset,
2645 );
26732646 let border = 2.0 / paint.scale;
26742647 for rect in [
26752648 [x0, y0, x1, y0 + border],
......@@ -2899,12 +2872,7 @@ fn append_outline_chrome(
28992872 let [x, y] = origin;
29002873 let (bounds, body_top) = outline_chrome(outline, pixel);
29012874 let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]];
2902 let [left, top, right, bottom] = [
2903 bounds[0] + dx,
2904 bounds[1] + dy,
2905 bounds[2] + dx,
2906 bounds[3] + dy,
2907 ];
2875 let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]);
29082876 let body_top = body_top + dy;
29092877 if outline.title {
29102878 primitives.push(Primitive::RoundedRect {
crates/canvas/src/interaction/scroll.rs+35-7
......@@ -10,25 +10,36 @@ pub struct Scroll {
1010
1111impl Scroll {
1212 /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond
13 /// it left and up.
14 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = ([f32; 4], f32)>) -> Self {
13 /// it up and toward the page's start: left, or right on a right-to-left page.
14 pub fn new(
15 viewport: Viewport,
16 bounds: impl Iterator<Item = ([f32; 4], f32)>,
17 rtl: bool,
18 ) -> Self {
1519 let mut min = [0.0_f32; 2];
1620 let mut max = [0.0_f32; 2];
21 let mut end = f32::NEG_INFINITY;
1722 for (rect, pad) in bounds {
1823 for axis in 0..2 {
1924 min[axis] = min[axis].min(rect[axis] * viewport.scale - pad);
2025 max[axis] = max[axis].max(rect[axis + 2]);
2126 }
27 end = end.max(rect[2] * viewport.scale + pad);
2228 }
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.
2431 for axis in 0..2 {
25 max[axis] =
26 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
32 max[axis] = if rtl && axis == 0 && end.is_finite() {
33 end - viewport.size[0] as f32
34 } else {
35 (max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32
36 }
37 .max(min[axis]);
2738 }
2839 Self { min, max }
2940 }
3041
31 pub fn clamp(&self, viewport: &mut Viewport) {
42 pub(crate) fn clamp(&self, viewport: &mut Viewport) {
3243 for axis in 0..2 {
3344 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);
3445 }
......@@ -49,6 +60,7 @@ mod tests {
4960 let scroll = Scroll::new(
5061 viewport,
5162 [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(),
63 false,
5264 );
5365 scroll.clamp(&mut viewport);
5466 assert_eq!(viewport.origin, [171.0, 51.0]);
......@@ -68,7 +80,23 @@ mod tests {
6880 ([-0.5, -22.35, 613.0, 141.3], 0.0),
6981 ([36.0, 86.4, 400.0, 180.0], 11.0),
7082 ];
71 let scroll = Scroll::new(viewport, bounds.into_iter());
83 let scroll = Scroll::new(viewport, bounds.into_iter(), false);
7284 assert_eq!(scroll.min, [-1.0, -44.7]);
7385 }
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 }
74102}
crates/canvas/src/interaction/tests.rs+14
......@@ -57,6 +57,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
5757 title: "Header".into(),
5858 created: Some(1),
5959 margin_origin: [36.0, 14.4],
60 rtl: false,
6061 color: None,
6162 rule_lines: None,
6263 definitions: Default::default(),
......@@ -590,6 +591,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
590591 title: String::new(),
591592 created: None,
592593 margin_origin: [0.0; 2],
594 rtl: false,
593595 color: None,
594596 rule_lines: None,
595597 definitions: Default::default(),
......@@ -719,6 +721,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
719721 identity: None,
720722 created: None,
721723 margin_origin: [36.0, 14.4],
724 rtl: false,
722725 color: None,
723726 rule_lines: None,
724727 definitions: Default::default(),
......@@ -1247,6 +1250,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
12471250 created: None,
12481251 title: String::new(),
12491252 margin_origin: [0.0; 2],
1253 rtl: false,
12501254 color: None,
12511255 rule_lines: None,
12521256 definitions: Default::default(),
......@@ -1445,6 +1449,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
14451449 created: None,
14461450 title: String::new(),
14471451 margin_origin: [0.0; 2],
1452 rtl: false,
14481453 color: None,
14491454 rule_lines: None,
14501455 definitions: Default::default(),
......@@ -1566,6 +1571,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15661571 identity: None,
15671572 created: None,
15681573 margin_origin: [36.0, 14.4],
1574 rtl: false,
15691575 color: None,
15701576 rule_lines: None,
15711577 definitions: Default::default(),
......@@ -1604,6 +1610,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {
16041610 identity: None,
16051611 created: None,
16061612 margin_origin: [36.0, 14.4],
1613 rtl: false,
16071614 color: None,
16081615 rule_lines: None,
16091616 definitions: Default::default(),
......@@ -2079,6 +2086,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
20792086 identity: None,
20802087 created: None,
20812088 margin_origin: [36.0, 14.4],
2089 rtl: false,
20822090 color: None,
20832091 rule_lines: None,
20842092 definitions: Default::default(),
......@@ -2171,6 +2179,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
21712179 identity: None,
21722180 created: None,
21732181 margin_origin: [36.0, 14.4],
2182 rtl: false,
21742183 color: None,
21752184 rule_lines: None,
21762185 definitions: Default::default(),
......@@ -2712,6 +2721,7 @@ fn art_draws_at_once_over_the_rule_lines_and_follows_undo() {
27122721 title: String::new(),
27132722 created: None,
27142723 margin_origin: [36.0, 14.4],
2724 rtl: false,
27152725 color: None,
27162726 rule_lines: Some(crate::template::RULE_LINES[2].1),
27172727 definitions: Default::default(),
......@@ -2832,6 +2842,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
28322842 identity: None,
28332843 created: None,
28342844 margin_origin: [36.0, 14.4],
2845 rtl: false,
28352846 color: None,
28362847 rule_lines: None,
28372848 definitions: Default::default(),
......@@ -2959,6 +2970,7 @@ fn misspelled_words_are_marked_and_corrected_in_one_edit() {
29592970 identity: None,
29602971 created: None,
29612972 margin_origin: [36.0, 14.4],
2973 rtl: false,
29622974 color: None,
29632975 rule_lines: None,
29642976 definitions: Default::default(),
......@@ -3072,6 +3084,7 @@ fn the_note_playing_scrolls_into_view() {
30723084 identity: None,
30733085 created: None,
30743086 margin_origin: [36.0, 14.4],
3087 rtl: false,
30753088 color: None,
30763089 rule_lines: None,
30773090 definitions: Default::default(),
......@@ -3129,6 +3142,7 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() {
31293142 identity: None,
31303143 created: None,
31313144 margin_origin: [36.0, 14.4],
3145 rtl: false,
31323146 color: None,
31333147 rule_lines: None,
31343148 definitions: Default::default(),
crates/canvas/src/language.rs+1-1
......@@ -135,7 +135,7 @@ pub fn lcid(tag: &str) -> u32 {
135135
136136/// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks
137137/// 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> {
139139 LCIDS
140140 .iter()
141141 .find(|(_, known)| *known == lcid)
crates/canvas/src/layout.rs+15-15
......@@ -42,11 +42,11 @@ const SUBSCRIPT_DROP: f32 = 0.08;
4242#[derive(Clone, Copy, Debug, Default, PartialEq)]
4343pub struct TextBrush {
4444 /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper.
45 pub color: Option<u32>,
46 pub highlight: Option<u32>,
45 pub(crate) color: Option<u32>,
46 pub(crate) highlight: Option<u32>,
4747 /// Points the run draws above the line's baseline: positive for a superscript,
4848 /// negative for a subscript. Line metrics and hit-testing keep the baseline.
49 pub rise: f32,
49 pub(crate) rise: f32,
5050}
5151
5252#[derive(Clone, Debug)]
......@@ -72,11 +72,11 @@ pub struct TextLayout {
7272/// Room kept in a line for something drawn inline, such as an equation, before the text at
7373/// byte `index`; the line grows to its ascent and descent.
7474#[derive(Clone, Copy, Debug, PartialEq)]
75pub struct InlineSpace {
76 pub index: usize,
77 pub width: f32,
78 pub ascent: f32,
79 pub descent: f32,
75pub(crate) struct InlineSpace {
76 pub(crate) index: usize,
77 pub(crate) width: f32,
78 pub(crate) ascent: f32,
79 pub(crate) descent: f32,
8080}
8181
8282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -106,8 +106,8 @@ impl fmt::Display for LayoutError {
106106impl std::error::Error for LayoutError {}
107107
108108/// What a run without a font or size of its own is laid out in.
109pub const DEFAULT_FONT: &str = "Arial";
110pub const DEFAULT_FONT_SIZE: f32 = 11.0;
109pub(crate) const DEFAULT_FONT: &str = "Arial";
110pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0;
111111
112112/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font
113113/// Licence files beside them, by the family each stands in for.
......@@ -425,7 +425,7 @@ impl TextEngine {
425425 }
426426
427427 /// Lays out `paragraph` keeping `spaces` inline, for what draws in them.
428 pub fn layout_with(
428 pub(crate) fn layout_with(
429429 &mut self,
430430 paragraph: &Paragraph,
431431 width: f32,
......@@ -531,7 +531,7 @@ impl TextEngine {
531531
532532impl TextLayout {
533533 /// Process-unique identity for caches of an externally immutable layout.
534 pub fn id(&self) -> u64 {
534 pub(crate) fn id(&self) -> u64 {
535535 self.id
536536 }
537537
......@@ -592,11 +592,11 @@ impl TextLayout {
592592 last.top + last.height
593593 }
594594
595 pub fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor {
595 pub(crate) fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor {
596596 Cursor::from_byte_index(&self.shaped, byte, affinity)
597597 }
598598
599 pub fn hit_test(&self, x: f32, y: f32) -> Cursor {
599 pub(crate) fn hit_test(&self, x: f32, y: f32) -> Cursor {
600600 let index = self
601601 .lines
602602 .partition_point(|line| line.top + line.height <= y)
......@@ -645,7 +645,7 @@ impl TextLayout {
645645
646646 /// Each line's part of `selection` as a mark under the text runs: its left, its right
647647 /// 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]> {
649649 selection
650650 .geometry(&self.shaped)
651651 .into_iter()
crates/canvas/src/lib.rs+11
......@@ -5,6 +5,7 @@ pub mod language;
55pub mod layout;
66pub mod math;
77pub mod outline;
8pub mod reading;
89pub mod search;
910pub mod spelling;
1011pub mod template;
......@@ -17,6 +18,16 @@ pub mod interaction;
1718#[cfg(feature = "pdf")]
1819pub 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
2031/// Parley's caret affinity mapped onto the page model's hidden-field affinity.
2132pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {
2233 match affinity {
crates/canvas/src/math.rs+4-4
......@@ -11,14 +11,14 @@ use onestore::page::text::Paragraph;
1111pub struct MathLayout {
1212 pub size: [f32; 2],
1313 /// COLORREF for rules and strokes, or `None` for the paper's ink.
14 pub color: Option<u32>,
14 pub(crate) color: Option<u32>,
1515 /// Distance from the top to the baseline of the equation's main row.
1616 pub baseline: f32,
17 pub items: Vec<MathItem>,
17 pub(crate) items: Vec<MathItem>,
1818}
1919
2020#[derive(Clone)]
21pub enum MathItem {
21pub(crate) enum MathItem {
2222 Text {
2323 layout: TextLayout,
2424 origin: [f32; 2],
......@@ -79,7 +79,7 @@ fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem {
7979 layout,
8080 origin: [origin[0] + dx, origin[1] + dy],
8181 },
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])),
8383 MathItem::Stroke { from, to, width } => MathItem::Stroke {
8484 from: [from[0] + dx, from[1] + dy],
8585 to: [to[0] + dx, to[1] + dy],
crates/canvas/src/outline.rs+26-26
......@@ -21,7 +21,7 @@ pub struct OutlineLayout {
2121 pub paragraphs: Vec<ParagraphLayout>,
2222 pub tables: Vec<TableLayout>,
2323 /// Pictures, files and handwriting that occupy a paragraph of their own.
24 pub objects: Vec<ObjectLayout>,
24 pub(crate) objects: Vec<ObjectLayout>,
2525 pub size: [f32; 2],
2626 /// One per root node, so an edit places anew only what it changes and moves what follows.
2727 blocks: Vec<Block>,
......@@ -49,37 +49,37 @@ struct Block {
4949
5050/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.
5151pub(crate) struct Relayout {
52 pub range: Range<usize>,
53 pub segment: OutlineLayout,
52 pub(crate) range: Range<usize>,
53 pub(crate) segment: OutlineLayout,
5454 /// Each following node's new top and flow state, up to the first that stays.
5555 moved: Vec<(f32, (f64, Option<f32>))>,
56 pub size: [f32; 2],
56 pub(crate) size: [f32; 2],
5757 widths: [f32; 2],
5858}
5959
6060#[derive(Clone)]
61pub struct ObjectLayout {
61pub(crate) struct ObjectLayout {
6262 /// The object's identity, which keys a picture's or file icon's decoded image.
63 pub id: ExGuid,
63 pub(crate) id: ExGuid,
6464 /// Where the picture, file icon, drawing or placeholder draws, outline-local.
65 pub rect: [f32; 4],
66 pub kind: ObjectKind,
65 pub(crate) rect: [f32; 4],
66 pub(crate) kind: ObjectKind,
6767 /// Outline-local bottom of the whole object, label included.
68 pub bottom: f32,
69 pub tags: Option<BlockTags>,
68 pub(crate) bottom: f32,
69 pub(crate) tags: Option<BlockTags>,
7070}
7171
7272/// The note tags of a table, picture or file, which OneNote centres on it in the tag column.
7373#[derive(Clone)]
74pub struct BlockTags {
74pub(crate) struct BlockTags {
7575 /// The paragraph holding the block.
76 pub paragraph: ExGuid,
76 pub(crate) paragraph: ExGuid,
7777 /// Its siblings' group, whose list markers the tags clear.
7878 parent: Option<ExGuid>,
7979 /// The block's left edge, outline-local.
8080 x: f32,
8181 /// Origins are outline-local.
82 pub tags: Vec<ParagraphTag>,
82 pub(crate) tags: Vec<ParagraphTag>,
8383}
8484
8585impl BlockTags {
......@@ -130,7 +130,7 @@ impl BlockTags {
130130}
131131
132132#[derive(Clone)]
133pub enum ObjectKind {
133pub(crate) enum ObjectKind {
134134 Picture,
135135 /// A file's icon with its name centered below.
136136 File(ParagraphLayout),
......@@ -141,7 +141,7 @@ pub enum ObjectKind {
141141}
142142
143143impl ObjectLayout {
144 pub fn label(&self) -> Option<&ParagraphLayout> {
144 pub(crate) fn label(&self) -> Option<&ParagraphLayout> {
145145 match &self.kind {
146146 ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label),
147147 ObjectKind::Picture | ObjectKind::Ink(_) => None,
......@@ -243,7 +243,7 @@ pub struct TableLayout {
243243 /// Cells are in paragraph order with disjoint visible ranges.
244244 pub cells: Vec<CellLayout>,
245245 pub borders: bool,
246 pub tags: Option<BlockTags>,
246 pub(crate) tags: Option<BlockTags>,
247247}
248248
249249#[derive(Clone)]
......@@ -251,12 +251,12 @@ pub struct CellLayout {
251251 pub id: ExGuid,
252252 pub rect: [f32; 4],
253253 /// Visible paragraph indices, including paragraphs in nested tables.
254 pub paragraphs: std::ops::Range<usize>,
254 pub(crate) paragraphs: std::ops::Range<usize>,
255255}
256256
257257impl CellLayout {
258258 /// 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] {
260260 [
261261 self.rect[0] - 2.7,
262262 self.rect[1],
......@@ -265,7 +265,7 @@ impl CellLayout {
265265 ]
266266 }
267267
268 pub fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> {
268 pub(crate) fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> {
269269 let [left, top, right, bottom] = self.text_bounds().map(f64::from);
270270 let rect = parley::BoundingBox::new(
271271 rect.x0.max(left),
......@@ -296,7 +296,7 @@ pub struct ParagraphLayout {
296296 pub math: Vec<crate::math::MathLayout>,
297297 /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its
298298 /// outline behind its lines, as OneNote marks a conflict page's conflicting changes.
299 pub band: Option<u32>,
299 pub(crate) band: Option<u32>,
300300}
301301
302302/// How the page draws a note tag.
......@@ -318,13 +318,13 @@ impl TagIcon {
318318 })
319319 }
320320
321 pub fn checkable(self) -> bool {
321 pub(crate) fn checkable(self) -> bool {
322322 matches!(self, Self::Symbol { shape, .. } if checkable(shape))
323323 }
324324}
325325
326326/// 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 {
328328 matches!(
329329 shape,
330330 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99
......@@ -498,9 +498,9 @@ pub struct ParagraphTag {
498498
499499impl ParagraphTag {
500500 /// 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;
502502 /// 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;
504504 /// Space between a tag and the leftmost list marker among its paragraph's siblings.
505505 const MARKER_GAP: f32 = 0.9;
506506
......@@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>(
12581258impl OutlineLayout {
12591259 /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each
12601260 /// 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 {
12621262 -self
12631263 .paragraphs
12641264 .iter()
......@@ -1296,7 +1296,7 @@ impl OutlineLayout {
12961296 }
12971297
12981298 /// 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> {
13001300 self.tables.iter().rev().find_map(|table| {
13011301 let cell = table
13021302 .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 @@
22//! case and diacritics; a page matches when all its words appear in its title or text, and
33//! pages whose titles hold every word come first, then the most recently modified.
44
5use crate::document::TextPosition;
5use crate::document::{TextPosition, descendants};
66use crate::editor::{CanvasEditor, Selection};
77use icu_normalizer::DecomposingNormalizerBorrowed;
88use onestore::ExGuid;
......@@ -67,7 +67,7 @@ impl Query {
6767 }
6868
6969 /// 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>> {
7171 let (folded, source) = fold_mapped(text);
7272 self.hits(&folded)
7373 .into_iter()
......@@ -180,25 +180,13 @@ pub fn shown(paragraph: &Paragraph) -> String {
180180/// Calls `visit` with each text paragraph of the page's outlines, in page order, tables
181181/// cell by cell.
182182fn 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 }
199183 for object in &page.objects {
200184 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 }
202190 }
203191 }
204192}
......@@ -206,32 +194,19 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph,
206194/// The text OneNote recognised in each of the page's pictures, or printed on a printout's
207195/// pages, in page order.
208196fn picture_text(page: &Page) -> Vec<&str> {
209 fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) {
210 for paragraph in paragraphs {
211 match &paragraph.content {
212 ParagraphContent::Image(image) => {
213 out.extend(image.text.as_ref().map(|text| text.text.as_str()))
214 }
215 ParagraphContent::Table(table) => {
216 for cell in table.rows.iter().flat_map(|row| &row.cells) {
217 walk(&cell.paragraphs, out);
218 }
219 }
220 _ => {}
221 }
222 }
223 }
224 let mut out = Vec::new();
225 for object in &page.objects {
226 match object {
227 PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out),
228 PageObject::Image(image) => {
229 out.extend(image.text.as_ref().map(|text| text.text.as_str()))
230 }
231 _ => {}
232 }
233 }
234 out
197 let pictures = page.objects.iter().flat_map(|object| match object {
198 PageObject::Outline(outline) => descendants(&outline.paragraphs, None)
199 .filter_map(|(_, _, node)| match &node.content {
200 ParagraphContent::Image(image) => Some(image),
201 _ => None,
202 })
203 .collect(),
204 PageObject::Image(image) => vec![image],
205 _ => Vec::new(),
206 });
207 pictures
208 .filter_map(|image| Some(image.text.as_ref()?.text.as_str()))
209 .collect()
235210}
236211
237212/// 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> {
6060
6161/// A marked word: misspelled, or repeating the word before it.
6262#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Mark {
63pub(crate) struct Mark {
6464 /// Bytes of the paragraph's text.
65 pub range: Range<usize>,
66 pub repeated: bool,
65 pub(crate) range: Range<usize>,
66 pub(crate) repeated: bool,
6767}
6868
6969/// A word of a paragraph: its bytes, its language, and whether its spelling is checked.
......@@ -352,7 +352,7 @@ impl Spelling {
352352
353353 /// The marks on `paragraph` once it is checked, less accepted words; until then none,
354354 /// and it is checked.
355 pub fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> {
355 pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> {
356356 let key = key(paragraph);
357357 let mut state = self.shared.state.lock().unwrap();
358358 if let Some(marks) = state.marks(key, paragraph) {
......@@ -366,7 +366,7 @@ impl Spelling {
366366
367367 /// The marks on `paragraph`, checked on this thread where not yet checked, as the
368368 /// 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> {
370370 let key = key(paragraph);
371371 if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) {
372372 return marks;
......@@ -378,7 +378,7 @@ impl Spelling {
378378 }
379379
380380 /// 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> {
382382 self.shared
383383 .dictionary
384384 .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
77[features]
88default = ["wgpu"]
99# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as
11# Windows 7 has no Direct3D 12.
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its
11# maker picks one, as Windows 7 has no Direct3D 12.
1212render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
13wgpu = ["render", "dep:wgpu"]
13wgpu = ["render", "dep:wgpu", "dep:windows"]
1414# Writes layers as PDF pages, text as real text in subset fonts.
1515pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1616
......@@ -28,6 +28,16 @@ subsetter = { version = "0.2.6", default-features = false, optional = true }
2828swash = { version = "0.2.10", optional = true }
2929wgpu = { 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
3141[dev-dependencies]
3242pollster = "0.4"
3343png = "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>) {
432432/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright
433433/// as AppKit's layers do, which blend in sRGB rather than linear light.
434434pub 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 };
449435 let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue;
450436 let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]);
451437 let shown: [f32; 3] = std::array::from_fn(|channel| {
452 let [under, over] = [under[channel], over[channel]].map(encode);
453 decode(under + opacity * (over - under))
438 let [under, over] = [under[channel], over[channel]].map(crate::encode);
439 crate::linear(under + opacity * (over - under))
454440 });
455441 let span = luminance(over) - luminance(under);
456442 let alpha = if span.abs() < 1e-4 {
crates/draw/src/lib.rs+20-2
......@@ -1,6 +1,6 @@
11//! What the page and the interface share: painting layers of glyphs, icons, paths,
2//! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its
3//! own transform and clip, and editing text within a laid-out line.
2//! images, rounded rectangles and pen strokes with wgpu, Direct3D 11 or OpenGL, each layer
3//! with its own transform and clip, and editing text within a laid-out line.
44
55pub mod edit;
66#[cfg(feature = "render")]
......@@ -8,3 +8,21 @@ mod render;
88
99#[cfg(feature = "render")]
1010pub 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 @@
11#[cfg(all(feature = "wgpu", windows))]
2mod d3d11;
3#[cfg(all(feature = "wgpu", windows))]
24mod dual;
35#[cfg(any(windows, not(feature = "wgpu")))]
46mod gl;
......@@ -19,8 +21,6 @@ use gl as backend;
1921use webgpu as backend;
2022
2123pub use backend::Target;
22#[cfg(all(feature = "wgpu", windows))]
23pub use gl::Target as GlTarget;
2424pub use icon::{Palette, picture_icon};
2525#[cfg(feature = "pdf")]
2626pub use pdf::{Sheet, pdf};
......@@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob;
3434use parley::fontique::Blob;
3535use std::{
3636 collections::{HashMap, HashSet},
37 ffi::CStr,
38 mem::offset_of,
3739 ops::Range,
3840};
3941use swash::{
......@@ -171,14 +173,25 @@ impl RasterImage {
171173 }
172174}
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.
175178struct Frame<'a> {
176179 vertices: &'a [Vertex],
180 groups: &'a [Vec<Batch>],
177181 batches: &'a [Batch],
178 groups: &'a [Group],
179182 images: &'a HashMap<u64, CachedImage>,
180183}
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
182195struct CachedImage {
183196 texture: backend::Image,
184197 bytes: u64,
......@@ -192,24 +205,58 @@ struct Batch {
192205 scissor: [u32; 4],
193206}
194207
195/// Batches that paint offscreen as one, then onto the target by `composite`'s vertices.
196struct Group {
197 batches: Range<usize>,
198 composite: Range<u32>,
199}
200
201/// How a batch meets what lies beneath it.
208/// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints
209/// a picture.
202210#[derive(Clone, Copy, PartialEq)]
203211enum Blend {
204212 Over,
205213 /// A premultiplied picture over it.
206214 Image(u64),
215 /// That group's offscreen picture over it.
216 Group(usize),
207217 /// Clearing it by the batch's coverage.
208218 Erase,
209219 /// Multiplying it by the batch's colour where covered.
210220 Multiply,
211221}
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
213260#[repr(C)]
214261#[derive(Clone, Copy, Pod, Zeroable)]
215262struct Vertex {
......@@ -228,6 +275,19 @@ struct Vertex {
228275 blur: f32,
229276}
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
231291#[derive(Hash, PartialEq, Eq)]
232292enum AtlasKey {
233293 Text {
......@@ -313,6 +373,18 @@ impl Motion {
313373 }
314374}
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
316388/// A layer's transform onto the target.
317389#[derive(Clone, Copy)]
318390struct Space {
......@@ -324,13 +396,18 @@ struct Space {
324396}
325397
326398impl 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
327409 fn visible_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> {
328 let rect = [
329 rect[0] * self.scale + self.origin[0],
330 rect[1] * self.scale + self.origin[1],
331 rect[2] * self.scale + self.origin[0],
332 rect[3] * self.scale + self.origin[1],
333 ];
410 let rect = self.rect(rect);
334411 if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] {
335412 return Err(RenderError::InvalidPrimitive);
336413 }
......@@ -508,7 +585,7 @@ pub struct Renderer {
508585 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
509586 images: HashMap<u64, CachedImage>,
510587 batches: Vec<Batch>,
511 groups: Vec<Group>,
588 groups: Vec<Vec<Batch>>,
512589 /// Batches before this one take no more primitives.
513590 barrier: usize,
514591 scaler: ScaleContext,
......@@ -516,10 +593,36 @@ pub struct Renderer {
516593 row_height: u32,
517594}
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
519622impl Renderer {
520 fn with_gpu(gpu: backend::Gpu) -> Self {
521 let renderer = Self {
522 gpu,
623 fn with_gpu(gpu: impl Into<backend::Gpu>) -> Self {
624 let mut renderer = Self {
625 gpu: gpu.into(),
523626 vertices: Vec::new(),
524627 glyphs: HashMap::new(),
525628 images: HashMap::new(),
......@@ -527,10 +630,10 @@ impl Renderer {
527630 groups: Vec::new(),
528631 barrier: 0,
529632 scaler: ScaleContext::with_max_entries(32),
530 pen: [1, 0],
531 row_height: 1,
633 pen: [0; 2],
634 row_height: 0,
532635 };
533 renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]);
636 renderer.clear_glyph_cache();
534637 renderer
535638 }
536639
......@@ -539,23 +642,12 @@ impl Renderer {
539642 self.gpu.max_texture_dimension()
540643 }
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
553645 fn glyph_limit(&self) -> usize {
554 MAX_GLYPHS * (self.atlas_side() / ATLAS_SIZE).pow(2) as usize
646 MAX_GLYPHS * (self.gpu.atlas_side() / ATLAS_SIZE).pow(2) as usize
555647 }
556648
557649 fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] {
558 let side = self.atlas_side() as f32;
650 let side = self.gpu.atlas_side() as f32;
559651 [
560652 glyph.x as f32 / side,
561653 glyph.y as f32 / side,
......@@ -564,13 +656,15 @@ impl Renderer {
564656 ]
565657 }
566658
567 /// The atlas's first texel, the white that solid fills sample.
659 /// The atlas's white texel.
568660 fn white(&self) -> [f32; 4] {
569 [0.5 / self.atlas_side() as f32; 4]
661 [0.5 / self.gpu.atlas_side() as f32; 4]
570662 }
571663
664 /// Empties the atlas but for its first texel, the white that solid fills sample.
572665 pub fn clear_glyph_cache(&mut self) {
573666 self.glyphs.clear();
667 self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]);
574668 self.pen = [1, 0];
575669 self.row_height = 1;
576670 }
......@@ -593,12 +687,12 @@ impl Renderer {
593687 images: self.images.len(),
594688 image_bytes,
595689 vertices: self.vertices.len(),
596 batches: self.batches.len(),
690 batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::<usize>(),
597691 vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(),
598692 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),
599693 glyph_capacity: self.glyphs.capacity(),
600694 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,
602696 vertex_buffer_bytes: VERTEX_BUFFER_BYTES,
603697 within_budget: self.glyphs.len() <= self.glyph_limit()
604698 && self.images.len() <= MAX_IMAGES
......@@ -633,16 +727,9 @@ impl Renderer {
633727 let mut active_images = HashSet::new();
634728 let mut image_bytes = 0;
635729 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 };
643730 for primitive in layer.primitives {
644731 if let Primitive::Image { image, rect } = primitive
645 && space.visible_rect(*rect)?.is_some()
732 && layer.space(size).visible_rect(*rect)?.is_some()
646733 && active_images.insert(image.id())
647734 {
648735 image_bytes += image.pixels().len() as u64;
......@@ -668,15 +755,16 @@ impl Renderer {
668755 };
669756 // Once cleared, the atlas holds only this frame's entries; past half full, the
670757 // 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());
672759 if !crowded {
673760 break;
674761 }
675762 if !cleared {
676763 self.clear_glyph_cache();
677764 cleared = true;
678 } else if self.atlas_side() * 2 <= limit {
679 self.grow_atlas();
765 } else if self.gpu.atlas_side() * 2 <= limit {
766 self.gpu.new_atlas(self.gpu.atlas_side() * 2);
767 self.clear_glyph_cache();
680768 } else if full {
681769 return Err(RenderError::AtlasFull);
682770 } else {
......@@ -685,8 +773,8 @@ impl Renderer {
685773 }
686774 let frame = Frame {
687775 vertices: &self.vertices,
688 batches: &self.batches,
689776 groups: &self.groups,
777 batches: &self.batches,
690778 images: &self.images,
691779 };
692780 self.gpu.submit(&frame, target, size, clear);
......@@ -713,13 +801,7 @@ impl Renderer {
713801 self.barrier = self.batches.len();
714802 group = motion.map(|motion| (motion, self.batches.len()));
715803 }
716 let space = Space {
717 size,
718 scale: layer.scale,
719 origin: layer.origin,
720 backdrop: layer.backdrop,
721 round: layer.round,
722 };
804 let space = layer.space(size);
723805 let bounds = match layer.clip {
724806 Some(clip) => space.scissor(clip),
725807 None => [0, 0, size[0], size[1]],
......@@ -737,12 +819,11 @@ impl Renderer {
737819 Ok(())
738820 }
739821
740 /// Makes the batches from `start` a group appearing by `motion`: the vertices that
741 /// paint its offscreen picture over the target, in strips so the turn stays in
742 /// perspective across the picture.
822 /// Makes the batches from `start` a group appearing by `motion`, leaving in their place
823 /// the batch that paints its offscreen picture over the target, in strips so the turn
824 /// stays in perspective across the picture.
743825 fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> {
744 let batches = start..self.batches.len();
745 if batches.is_empty() {
826 if start == self.batches.len() {
746827 return Ok(());
747828 }
748829 let [width, height] = size.map(|side| side as f32);
......@@ -771,12 +852,7 @@ impl Renderer {
771852 position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height],
772853 uv: [x / width, if flipped { 1.0 - v } else { v }],
773854 color: [motion.opacity; 4],
774 local: [0.0; 2],
775 shape: [0.0; 4],
776 stroke: 0.0,
777 clip_local: [0.0; 2],
778 clip: [0.0; 3],
779 blur: 0.0,
855 ..Vertex::zeroed()
780856 }
781857 };
782858 for strip in 0..strips {
......@@ -790,10 +866,13 @@ impl Renderer {
790866 ];
791867 self.vertices.extend([a, b, c, a, c, d]);
792868 }
793 self.groups.push(Group {
794 batches,
795 composite: from..self.vertices.len() as u32,
869 let batches = self.batches.split_off(start);
870 self.batches.push(Batch {
871 vertices: from..self.vertices.len() as u32,
872 blend: Blend::Group(self.groups.len()),
873 scissor: [0, 0, size[0], size[1]],
796874 });
875 self.groups.push(batches);
797876 Ok(())
798877 }
799878
......@@ -815,10 +894,7 @@ impl Renderer {
815894 tint,
816895 palette,
817896 } => {
818 let origin = [
819 space.origin[0] + origin[0] * space.scale,
820 space.origin[1] + origin[1] * space.scale,
821 ];
897 let origin = space.point(*origin);
822898 self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?;
823899 }
824900 Primitive::Path {
......@@ -838,10 +914,7 @@ impl Renderer {
838914 clip,
839915 ink,
840916 } => {
841 let origin = [
842 space.origin[0] + origin[0] * space.scale,
843 space.origin[1] + origin[1] * space.scale,
844 ];
917 let origin = space.point(*origin);
845918 if origin.iter().any(|v| !v.is_finite()) {
846919 return Err(RenderError::InvalidPrimitive);
847920 }
......@@ -858,17 +931,7 @@ impl Renderer {
858931 text.runs(&mut |run| self.glyph_run(space, run, *ink))?;
859932 }
860933 Primitive::Rect { rect, color } => {
861 self.quad(
862 space,
863 [
864 rect[0] * space.scale + space.origin[0],
865 rect[1] * space.scale + space.origin[1],
866 rect[2] * space.scale + space.origin[0],
867 rect[3] * space.scale + space.origin[1],
868 ],
869 self.white(),
870 *color,
871 )?;
934 self.quad(space, space.rect(*rect), self.white(), *color)?;
872935 }
873936 Primitive::RoundedRect {
874937 rect,
......@@ -993,7 +1056,7 @@ impl Renderer {
9931056 return Ok(());
9941057 }
9951058 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 {
9971060 return Err(RenderError::AtlasFull);
9981061 }
9991062 let color = run
......@@ -1119,7 +1182,7 @@ impl Renderer {
11191182
11201183 fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> {
11211184 let p = image.placement;
1122 let side = self.atlas_side();
1185 let side = self.gpu.atlas_side();
11231186 if p.width + 2 > side || p.height + 2 > side {
11241187 return Err(RenderError::AtlasFull);
11251188 }
......@@ -1173,7 +1236,7 @@ impl Renderer {
11731236 {
11741237 return Ok(());
11751238 }
1176 if size + 2.0 > self.atlas_side() as f32 {
1239 if size + 2.0 > self.gpu.atlas_side() as f32 {
11771240 return Err(RenderError::AtlasFull);
11781241 }
11791242 let size = size as u32;
......@@ -1226,12 +1289,7 @@ impl Renderer {
12261289 width
12271290 }
12281291 };
1229 let rect = [
1230 rect[0] * space.scale + space.origin[0],
1231 rect[1] * space.scale + space.origin[1],
1232 rect[2] * space.scale + space.origin[0],
1233 rect[3] * space.scale + space.origin[1],
1234 ];
1292 let rect = space.rect(rect);
12351293 let start = self.vertices.len();
12361294 // A pixel of margin holds the antialiased fringe outside the edge.
12371295 let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0];
......@@ -1273,7 +1331,7 @@ impl Renderer {
12731331 if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 {
12741332 return Err(RenderError::InvalidPrimitive);
12751333 }
1276 let rect = [0, 1, 2, 3].map(|i| rect[i] * space.scale + space.origin[i % 2]);
1334 let rect = space.rect(rect);
12771335 let sigma = blur * space.scale / 2.0;
12781336 let reach = 3.0 * sigma;
12791337 let start = self.vertices.len();
......@@ -1305,8 +1363,9 @@ impl Renderer {
13051363 style: PathStyle,
13061364 colors: [[f32; 4]; 2],
13071365 ) -> Result<(), RenderError> {
1308 let [x, y] = [0, 1]
1309 .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25);
1366 let [x, y] = space
1367 .point(origin)
1368 .map(|value| (value * 4.0).round() * 0.25);
13101369 let (kind, width) = match style {
13111370 PathStyle::Fill | PathStyle::Erase => (0, 0.0),
13121371 PathStyle::Stroke(width) => (1, width),
......@@ -1400,13 +1459,7 @@ impl Renderer {
14001459 round: bool,
14011460 color: [f32; 4],
14021461 ) -> Result<(), RenderError> {
1403 let pixel = |p: [f32; 2]| {
1404 [
1405 p[0] * space.scale + space.origin[0],
1406 p[1] * space.scale + space.origin[1],
1407 ]
1408 };
1409 let [from, to] = [pixel(from), pixel(to)];
1462 let [from, to] = [space.point(from), space.point(to)];
14101463 // Hairlines stay one device pixel wide.
14111464 let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5);
14121465 let radius = radii[0].max(radii[1]);
......@@ -1463,14 +1516,12 @@ impl Renderer {
14631516 x * 2.0 / space.size[0] as f32 - 1.0,
14641517 1.0 - y * 2.0 / space.size[1] as f32,
14651518 ],
1466 uv: [0.5 / self.atlas_side() as f32; 2],
1519 uv: [self.white()[0]; 2],
14671520 color,
14681521 local,
14691522 shape,
1470 stroke: 0.0,
1471 clip_local: [0.0; 2],
1472 clip: [0.0; 3],
14731523 blur,
1524 ..Vertex::zeroed()
14741525 });
14751526 }
14761527 Ok(())
......@@ -1520,11 +1571,7 @@ impl Renderer {
15201571 uv,
15211572 color,
15221573 local,
1523 shape: [0.0; 4],
1524 stroke: 0.0,
1525 clip_local: [0.0; 2],
1526 clip: [0.0; 3],
1527 blur: 0.0,
1574 ..Vertex::zeroed()
15281575 });
15291576 }
15301577 Ok(())
......@@ -1585,12 +1632,7 @@ fn soften(
15851632}
15861633
15871634fn srgb_byte(value: f32) -> u8 {
1588 let srgb = if value <= 0.0031308 {
1589 value * 12.92
1590 } else {
1591 1.055 * value.powf(1.0 / 2.4) - 0.055
1592 };
1593 (srgb * 255.0).round() as u8
1635 (crate::encode(value) * 255.0).round() as u8
15941636}
15951637
15961638/// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light,
......@@ -1599,11 +1641,11 @@ fn srgb_byte(value: f32) -> u8 {
15991641/// nearly untouched.
16001642fn text_coverage(tone: u8) -> [u8; 256] {
16011643 let encoded = f32::from(tone) / 16.0;
1602 let ink = linear(encoded);
1644 let ink = crate::linear(encoded);
16031645 std::array::from_fn(|coverage| {
16041646 let coverage = coverage as f32 / 255.0;
16051647 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)
16071649 } else {
16081650 coverage
16091651 };
......@@ -1611,14 +1653,6 @@ fn text_coverage(tone: u8) -> [u8; 256] {
16111653 })
16121654}
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
16221656/// Least OKLab lightness difference between text and the backdrop it stays legible on.
16231657const LEGIBLE: f32 = 0.4;
16241658
......@@ -1670,7 +1704,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {
16701704
16711705/// Linear RGBA of an opaque sRGB colour.
16721706pub 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));
16741708 [red, green, blue, 1.0]
16751709}
16761710
......@@ -1681,18 +1715,18 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {
16811715
16821716/// The hue in degrees of a linear colour, as it looks in sRGB.
16831717pub fn hue(color: [f32; 4]) -> f32 {
1684 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {
1685 if value <= 0.003_130_8 {
1686 value * 12.92
1687 } else {
1688 1.055 * value.powf(1.0 / 2.4) - 0.055
1689 }
1690 });
1691 let max = r.max(g).max(b);
1692 let range = max - r.min(g).min(b);
1718 to_hsl([color[0], color[1], color[2]].map(crate::encode))[0]
1719}
1720
1721/// Hue in degrees, saturation and lightness of an encoded sRGB colour.
1722fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
1723 let [max, min] = [r.max(g).max(b), r.min(g).min(b)];
1724 let lightness = (max + min) / 2.0;
1725 let range = max - min;
16931726 if range == 0.0 {
1694 return 0.0;
1727 return [0.0, 0.0, lightness];
16951728 }
1729 let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs());
16961730 let sector = if max == r {
16971731 (g - b) / range
16981732 } else if max == g {
......@@ -1700,7 +1734,16 @@ pub fn hue(color: [f32; 4]) -> f32 {
17001734 } else {
17011735 (r - g) / range + 4.0
17021736 };
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 })
17041747}
17051748
17061749/// 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];
17081751
17091752/// An sRGB hue, saturation and lightness as linear RGBA.
17101753pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] {
1711 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
1712 let channel = |offset: f32| {
1713 let k = (offset + hue / 30.0).rem_euclid(12.0);
1714 let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0);
1715 (value * 255.0).round().clamp(0.0, 255.0) as u8
1716 };
1717 srgb(channel(0.0), channel(8.0), channel(4.0))
1754 let [red, green, blue] = from_hsl([hue, saturation, lightness])
1755 .map(|value| (value * 255.0).round().clamp(0.0, 255.0) as u8);
1756 srgb(red, green, blue)
17181757}
17191758
17201759#[cfg(all(test, feature = "wgpu"))]
......@@ -2547,13 +2586,13 @@ mod tests {
25472586 target.draw(&mut renderer, &page(&primitives)).unwrap();
25482587 }
25492588 assert_eq!(
2550 renderer.atlas_side(),
2589 renderer.gpu.atlas_side(),
25512590 ATLAS_SIZE,
25522591 "frames each needing a third of the atlas evict, never grow it"
25532592 );
25542593 let primitives = icons(0..3 * one_atlas);
25552594 target.draw(&mut renderer, &page(&primitives)).unwrap();
2556 assert!(renderer.atlas_side() > ATLAS_SIZE);
2595 assert!(renderer.gpu.atlas_side() > ATLAS_SIZE);
25572596 assert!(renderer.glyphs.len() >= 3 * one_atlas);
25582597 let capture = target.capture(&renderer);
25592598 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//! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1
2//! where it doesn't, as on Windows 7. Whoever makes the renderer picks one.
3use super::{gl, webgpu, *};
1//! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11
2//! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the
3//! renderer picks one.
4use super::{d3d11, gl, webgpu, *};
5use std::ffi::c_void;
46
57/// What a frame is drawn into, for the backend the renderer draws with.
68pub enum Target {
79 Wgpu(wgpu::TextureView),
10 D3d11(d3d11::Target),
811 Gl(gl::Target),
912}
1013
......@@ -14,14 +17,19 @@ impl From<wgpu::TextureView> for Target {
1417 }
1518}
1619
17impl From<gl::Target> for Target {
18 fn from(target: gl::Target) -> Self {
19 Self::Gl(target)
20impl Target {
21 pub fn size(&self) -> [u32; 2] {
22 match self {
23 Self::Wgpu(view) => [view.texture().width(), view.texture().height()],
24 Self::D3d11(target) => target.size(),
25 Self::Gl(target) => target.size(),
26 }
2027 }
2128}
2229
2330pub(super) enum Image {
2431 Wgpu(webgpu::Image),
32 D3d11(d3d11::Image),
2533 Gl(gl::Image),
2634}
2735
......@@ -29,7 +37,16 @@ impl AsRef<webgpu::Image> for Image {
2937 fn as_ref(&self) -> &webgpu::Image {
3038 match self {
3139 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"),
3350 }
3451 }
3552}
......@@ -38,74 +55,109 @@ impl AsRef<gl::Image> for Image {
3855 fn as_ref(&self) -> &gl::Image {
3956 match self {
4057 Self::Gl(image) => image,
41 Self::Wgpu(_) => unreachable!("Images belong to the renderer's backend"),
58 _ => unreachable!("Images belong to the renderer's backend"),
4259 }
4360 }
4461}
4562
4663pub(super) enum Gpu {
4764 Wgpu(webgpu::Gpu),
65 D3d11(d3d11::Gpu),
4866 Gl(gl::Gpu),
4967}
5068
51impl Renderer {
52 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
53 Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format)))
69impl From<webgpu::Gpu> for Gpu {
70 fn from(gpu: webgpu::Gpu) -> Self {
71 Self::Wgpu(gpu)
72 }
73}
74
75impl From<gl::Gpu> for Gpu {
76 fn from(gpu: gl::Gpu) -> Self {
77 Self::Gl(gpu)
5478 }
79}
5580
56 /// Draws with the OpenGL context current on this thread, which must stay current
57 /// whenever the renderer or a `Target` is used.
58 pub fn opengl() -> Result<Self, String> {
59 gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu)))
81impl Renderer {
82 /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's
83 /// description.
84 pub fn direct3d11(window: *mut c_void) -> Result<(Self, String), String> {
85 d3d11::Gpu::new(window)
86 .map(|(gpu, adapter)| (Self::with_gpu(Gpu::D3d11(gpu)), adapter))
87 .map_err(|error| error.to_string())
6088 }
89}
6190
62 /// Copies `target` to the OpenGL context's window, as `gl`'s renderer does.
63 pub fn present_translucent(&self, target: &gl::Target) {
64 if let Gpu::Gl(gpu) = &self.gpu {
65 gpu.present_translucent(target);
91/// `$gpu` as whichever backend it holds, named `$backend` in `$body`.
92macro_rules! each {
93 ($gpu:expr, $backend:ident => $body:expr) => {
94 match $gpu {
95 Gpu::Wgpu($backend) => $body,
96 Gpu::D3d11($backend) => $body,
97 Gpu::Gl($backend) => $body,
6698 }
67 }
99 };
68100}
69101
70102impl Gpu {
71 pub(super) fn flipped(&self) -> bool {
103 pub(super) fn target(&self, size: [u32; 2]) -> Result<Target, String> {
72104 match self {
73 Self::Wgpu(gpu) => gpu.flipped(),
74 Self::Gl(gpu) => gpu.flipped(),
105 Self::D3d11(gpu) => gpu
106 .target(size)
107 .map(Target::D3d11)
108 .map_err(|error| error.to_string()),
109 Self::Gl(gpu) => gpu.target(size).map(Target::Gl),
110 Self::Wgpu(_) => unreachable!("wgpu's targets are its surface's"),
75111 }
76112 }
77113
78 pub(super) fn max_texture_dimension(&self) -> u32 {
79 match self {
80 Self::Wgpu(gpu) => gpu.max_texture_dimension(),
81 Self::Gl(gpu) => gpu.max_texture_dimension(),
114 pub(super) fn present(&self, target: &Target, translucent: bool) {
115 match (self, target) {
116 // Direct3D 11 premultiplies every frame again, which leaves opaque ones be.
117 (Self::D3d11(gpu), Target::D3d11(target)) => {
118 if let Err(error) = gpu.present(target) {
119 eprintln!("Presenting failed: {error}");
120 }
121 }
122 (Self::Gl(gpu), Target::Gl(target)) => gpu.present(target, translucent),
123 _ => unreachable!("Frames are drawn into the renderer's backend's targets"),
82124 }
83125 }
84126
85 pub(super) fn atlas_side(&self) -> u32 {
86 match self {
87 Self::Wgpu(gpu) => gpu.atlas_side(),
88 Self::Gl(gpu) => gpu.atlas_side(),
127 pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> {
128 match (self, target) {
129 (Self::D3d11(gpu), Target::D3d11(target)) => {
130 gpu.read_pixels(target).map_err(|error| error.to_string())
131 }
132 (Self::Gl(gpu), Target::Gl(target)) => gpu.read_pixels(target),
133 _ => unreachable!("Snapshots come from the renderer's backend's targets"),
89134 }
90135 }
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
92149 pub(super) fn new_atlas(&mut self, side: u32) {
93 match self {
94 Self::Wgpu(gpu) => gpu.new_atlas(side),
95 Self::Gl(gpu) => gpu.new_atlas(side),
96 }
150 each!(self, gpu => gpu.new_atlas(side))
97151 }
98152
99153 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
100 match self {
101 Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba),
102 Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba),
103 }
154 each!(self, gpu => gpu.write_atlas(origin, size, rgba))
104155 }
105156
106157 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
107158 match self {
108159 Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)),
160 Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)),
109161 Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)),
110162 }
111163 }
......@@ -119,6 +171,7 @@ impl Gpu {
119171 ) {
120172 match (self, target) {
121173 (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),
122175 (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear),
123176 _ => unreachable!("Frames are drawn into the renderer's backend's targets"),
124177 }
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 @@
77use super::*;
88use std::{
99 ffi::{CStr, CString, c_char, c_void},
10 mem::offset_of,
1110 ptr,
1211};
1312
......@@ -250,9 +249,6 @@ functions! {
250249 );
251250}
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
256252/// An sRGB RGBA texture, deleted with its value.
257253pub(super) struct Image {
258254 name: GLuint,
......@@ -305,9 +301,9 @@ pub struct Target {
305301}
306302
307303impl Target {
308 /// Needs the renderer's context current, as every method here does.
309 pub fn new(size: [u32; 2]) -> Result<Self, String> {
310 let texture = Image::new(size, NEAREST, None);
304 /// Sampled with `filter`.
305 fn new(size: [u32; 2], filter: GLint) -> Result<Self, String> {
306 let texture = Image::new(size, filter, None);
311307 let mut framebuffer = 0;
312308 unsafe {
313309 glGenFramebuffersEXT(1, &mut framebuffer);
......@@ -329,53 +325,6 @@ impl Target {
329325 pub fn size(&self) -> [u32; 2] {
330326 self.texture.size
331327 }
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 }
379328}
380329
381330impl Drop for Target {
......@@ -396,38 +345,10 @@ pub(super) struct Gpu {
396345 groups: Vec<Target>,
397346}
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
427348impl Gpu {
428349 pub(super) fn new() -> Result<Self, String> {
429350 load()?;
430 let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect();
351 let names = ATTRIBUTES.map(|(name, _, _)| name);
431352 let draw = unsafe { program(include_str!("../draw.glsl"), &names)? };
432353 let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? };
433354 let [mut buffer, mut triangle] = [0; 2];
......@@ -445,7 +366,7 @@ impl Gpu {
445366 );
446367 glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture);
447368 }
448 Ok(Gpu {
369 Ok(Self {
449370 program: draw,
450371 buffer,
451372 translucent,
......@@ -456,7 +377,36 @@ impl Gpu {
456377 })
457378 }
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 }
460410 let [width, height] = target.size().map(|side| side as f32);
461411 let program = self.translucent;
462412 unsafe {
......@@ -518,6 +468,31 @@ impl Gpu {
518468 }
519469 }
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
521496 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
522497 Image::new(image.size, LINEAR, Some(image.pixels()))
523498 }
......@@ -539,14 +514,9 @@ impl Gpu {
539514 self.groups.clear();
540515 }
541516 while self.groups.len() < frame.groups.len() {
542 match Target::new(size) {
543 Ok(picture) => unsafe {
544 // Filtered, so a picture leaning back stays smooth.
545 glBindTexture(TEXTURE_2D, picture.texture.name);
546 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR);
547 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR);
548 self.groups.push(picture);
549 },
517 // Filtered, so a picture leaning back stays smooth.
518 match Target::new(size, LINEAR) {
519 Ok(picture) => self.groups.push(picture),
550520 Err(error) => {
551521 eprintln!("{error}");
552522 return;
......@@ -555,36 +525,30 @@ impl Gpu {
555525 }
556526 let [width, height] = size.map(|side| side as GLsizei);
557527 let gpu = &*self;
558 let begin = |target: &Target, clear: [f32; 4]| unsafe {
528 let paint = |target: &Target, clear: [f32; 4], batches: &[Batch]| unsafe {
559529 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
560530 glDisable(SCISSOR_TEST);
561531 glClearColor(clear[0], clear[1], clear[2], clear[3]);
562532 glClear(COLOR_BUFFER_BIT);
563533 glEnable(SCISSOR_TEST);
564 };
565 let draw = |batches: &[Batch]| unsafe {
566534 for batch in batches {
567535 let [x, y, w, h] = batch.scissor.map(|value| value as GLint);
568536 glScissor(x, height - y - h, w, h);
569 let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend {
570 Blend::Over => (
571 gpu.atlas.name,
572 [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA],
573 ),
574 Blend::Image(id) => {
575 let image: &Image = frame.images[&id].texture.as_ref();
576 (image.name, PREMULTIPLIED)
577 }
578 Blend::Erase => (
579 gpu.atlas.name,
580 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],
581 ),
582 Blend::Multiply => {
583 (gpu.atlas.name, [DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE])
584 }
585 };
537 let [src_rgb, dst_rgb, src_alpha, dst_alpha] = Blend::STATES[batch.blend.state()]
538 .map(|factor| match factor {
539 Factor::Zero => ZERO,
540 Factor::One => ONE,
541 Factor::SourceAlpha => SRC_ALPHA,
542 Factor::OneMinusSourceAlpha => ONE_MINUS_SRC_ALPHA,
543 Factor::Destination => DST_COLOR,
544 });
586545 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);
588552 glDrawArrays(
589553 TRIANGLES,
590554 batch.vertices.start as GLint,
......@@ -605,38 +569,22 @@ impl Gpu {
605569 frame.vertices.as_ptr().cast(),
606570 STREAM_DRAW,
607571 );
608 for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() {
572 for (index, (_, floats, offset)) in ATTRIBUTES.into_iter().enumerate() {
609573 glEnableVertexAttribArray(index as GLuint);
610574 glVertexAttribPointer(
611575 index as GLuint,
612 *components,
576 floats as GLint,
613577 FLOAT,
614578 0,
615579 size_of::<Vertex>() as GLsizei,
616 ptr::without_provenance(*offset),
580 ptr::without_provenance(offset),
617581 );
618582 }
619583 glEnable(BLEND);
620 for (group, picture) in frame.groups.iter().zip(&gpu.groups) {
621 begin(picture, [0.0; 4]);
622 draw(&frame.batches[group.batches.clone()]);
623 }
624 begin(target, clear);
625 let mut next = 0;
626 for (group, picture) in frame.groups.iter().zip(&gpu.groups) {
627 draw(&frame.batches[next..group.batches.start]);
628 glScissor(0, 0, width, height);
629 let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED;
630 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
631 glBindTexture(TEXTURE_2D, picture.texture.name);
632 glDrawArrays(
633 TRIANGLES,
634 group.composite.start as GLint,
635 group.composite.len() as GLsizei,
636 );
637 next = group.batches.end;
584 for (batches, picture) in frame.groups.iter().zip(&gpu.groups) {
585 paint(picture, [0.0; 4], batches);
638586 }
639 draw(&frame.batches[next..]);
587 paint(target, clear, frame.batches);
640588 glDisable(SCISSOR_TEST);
641589 glDisable(BLEND);
642590 glBindFramebufferEXT(FRAMEBUFFER, 0);
crates/draw/src/render/icon.rs+20-62
......@@ -1,3 +1,4 @@
1use super::{from_hsl, to_hsl};
12use swash::{
23 scale::image::{Content, Image},
34 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
140141 let [x, y] = [index % side as usize, index / side as usize]
141142 .map(|device| device as f32 + 0.5)
142143 .map(|device| device * 16.0 / size);
143 let color = color([x, y]);
144 for channel in 0..3 {
145 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
146 }
147 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
144 over(pixel, color([x, y]), alpha);
148145 }
149146 }
150147 }
......@@ -224,13 +221,16 @@ fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) {
224221 for (column, row, color) in shown.enumerate_pixels() {
225222 let pixel = &mut pixels[((top + row) * side + left + column) as usize];
226223 let [r, g, b, a] = color.0;
227 let alpha = f32::from(a) / 255.0;
228 let color = super::srgb(r, g, b);
229 for channel in 0..3 {
230 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
231 }
232 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
224 over(pixel, super::srgb(r, g, b), f32::from(a) / 255.0);
225 }
226}
227
228/// Paints `color` at `alpha` over premultiplied `pixel`.
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);
233232 }
233 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
234234}
235235
236236fn hex(value: &str) -> [f32; 4] {
......@@ -364,11 +364,11 @@ impl Shift {
364364 let mean: [f32; 3] = std::array::from_fn(|channel| {
365365 colors
366366 .iter()
367 .map(|color| encode(color[channel]))
367 .map(|color| crate::encode(color[channel]))
368368 .sum::<f32>()
369369 / count
370370 });
371 let [from, to] = [mean, target.map(encode)].map(hsl);
371 let [from, to] = [mean, target.map(crate::encode)].map(to_hsl);
372372 (count > 0.0).then(|| {
373373 Self([
374374 to[0] - from[0],
......@@ -379,61 +379,19 @@ impl Shift {
379379 }
380380
381381 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));
383384 let [turn, scale, lift] = self.0;
384 let [r, g, b] = rgb([
385 let [r, g, b] = from_hsl([
385386 (hue + turn).rem_euclid(360.0),
386387 (saturation * scale).clamp(0.0, 1.0),
387388 (lightness + lift).clamp(0.0, 1.0),
388389 ])
389 .map(decode);
390 .map(crate::linear);
390391 [r, g, b, color[3]]
391392 }
392393}
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
437395/// The tight box around a path's geometry, curves included, as SVG's bounding box is.
438396fn bounds(data: &str) -> [[f32; 2]; 2] {
439397 struct Sampled {
......@@ -567,11 +525,11 @@ mod tests {
567525 let image = rasterize(&[ART], 16, [0.0; 3], palette);
568526 let at = (y * 16 + 8) * 4;
569527 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])
571529 };
572530 let plain = Palette::default();
573531 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)),
575533 ..plain
576534 };
577535 let [light, dark] = [2, 6].map(|y| pixel(&green, y));
......@@ -583,7 +541,7 @@ mod tests {
583541 assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02);
584542 assert_eq!(pixel(&green, 10), pixel(&plain, 10));
585543 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);
587545 }
588546
589547 #[test]
crates/draw/src/render/pdf.rs+32-37
......@@ -37,7 +37,6 @@ pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result<Vec<u8>, RenderError> {
3737 images: HashMap::new(),
3838 states: HashMap::new(),
3939 scale: ScaleContext::new(),
40 rows: None,
4140 };
4241 let catalog = document.reserve();
4342 let tree = document.reserve();
......@@ -101,9 +100,6 @@ struct Document {
101100 /// Graphics states by fill and stroke opacity bits and whether they multiply.
102101 states: HashMap<(u32, bool), Ref>,
103102 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]>,
107103}
108104
109105#[derive(Hash, PartialEq, Eq)]
......@@ -258,7 +254,9 @@ impl Document {
258254 .clip_nonzero()
259255 .end_path();
260256 }
261 self.rows = layer
257 // Lines of text outside a clipping layer are left out, so that no sheet's text
258 // layer holds text it does not show.
259 let rows = layer
262260 .clip
263261 .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale));
264262 content.transform([
......@@ -270,7 +268,7 @@ impl Document {
270268 layer.origin[1],
271269 ]);
272270 for primitive in layer.primitives {
273 self.primitive(&mut content, &mut used, primitive)?;
271 self.primitive(&mut content, &mut used, primitive, rows)?;
274272 }
275273 content.restore_state();
276274 }
......@@ -290,28 +288,30 @@ impl Document {
290288 content.set_fill_rgb(red, green, blue);
291289 }
292290 if color[3] < 1.0 {
293 let state = self.state(color[3], false);
294 Used::add(&mut used.states, state);
295 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
291 self.opacity(content, used, color[3], false);
296292 }
297293 }
298294
299 fn state(&mut self, opacity: f32, multiply: bool) -> Ref {
300 let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply);
301 if let Some(state) = self.states.get(&key) {
302 return *state;
303 }
304 let state = self.reserve();
305 let mut writer = self.pdf.ext_graphics(state);
306 writer
307 .non_stroking_alpha(opacity.clamp(0.0, 1.0))
308 .stroking_alpha(opacity.clamp(0.0, 1.0));
309 if multiply {
310 writer.blend_mode(BlendMode::Multiply);
311 }
312 writer.finish();
313 self.states.insert(key, state);
314 state
295 /// Paints at `opacity`, multiplying what lies beneath if `multiply`.
296 fn opacity(&mut self, content: &mut Content, used: &mut Used, opacity: f32, multiply: bool) {
297 let opacity = opacity.clamp(0.0, 1.0);
298 let key = (opacity.to_bits(), multiply);
299 let state = match self.states.get(&key) {
300 Some(state) => *state,
301 None => {
302 let state = self.reserve();
303 let mut writer = self.pdf.ext_graphics(state);
304 writer.non_stroking_alpha(opacity).stroking_alpha(opacity);
305 if multiply {
306 writer.blend_mode(BlendMode::Multiply);
307 }
308 writer.finish();
309 self.states.insert(key, state);
310 state
311 }
312 };
313 Used::add(&mut used.states, state);
314 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
315315 }
316316
317317 fn primitive(
......@@ -319,6 +319,7 @@ impl Document {
319319 content: &mut Content,
320320 used: &mut Used,
321321 primitive: &Primitive<'_>,
322 rows: Option<[f32; 2]>,
322323 ) -> Result<(), RenderError> {
323324 match primitive {
324325 Primitive::Text {
......@@ -334,7 +335,7 @@ impl Document {
334335 .clip_nonzero()
335336 .end_path();
336337 }
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))?;
338339 content.restore_state();
339340 }
340341 Primitive::Icon {
......@@ -364,9 +365,7 @@ impl Document {
364365 };
365366 content.save_state();
366367 if tint[3] < 1.0 {
367 let state = self.state(tint[3], false);
368 Used::add(&mut used.states, state);
369 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
368 self.opacity(content, used, tint[3], false);
370369 }
371370 self.place(
372371 content,
......@@ -603,9 +602,7 @@ impl Document {
603602 color,
604603 } => {
605604 content.save_state();
606 let state = self.state(color[3], true);
607 Used::add(&mut used.states, state);
608 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
605 self.opacity(content, used, color[3], true);
609606 let [red, green, blue] = encoded(*color);
610607 content
611608 .set_stroke_rgb(red, green, blue)
......@@ -649,7 +646,7 @@ impl Document {
649646 return [pixel[0], pixel[1], pixel[2], alpha];
650647 }
651648 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);
653650 srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0))
654651 });
655652 [red, green, blue, alpha]
......@@ -756,15 +753,13 @@ impl Document {
756753 run: GlyphRun<'_>,
757754 origin: [f32; 2],
758755 ink: [f32; 4],
756 rows: Option<[f32; 2]>,
759757 ) -> Result<(), RenderError> {
760758 let [top, bottom] = [
761759 origin[1] + run.line[0],
762760 origin[1] + run.line[0] + run.line[1],
763761 ];
764 if self
765 .rows
766 .is_some_and(|[first, last]| bottom <= first || top >= last)
767 {
762 if rows.is_some_and(|[first, last]| bottom <= first || top >= last) {
768763 return Ok(());
769764 }
770765 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 {
1717pub(super) struct Gpu {
1818 device: wgpu::Device,
1919 queue: wgpu::Queue,
20 pipeline: wgpu::RenderPipeline,
21 image_pipeline: wgpu::RenderPipeline,
22 erase_pipeline: wgpu::RenderPipeline,
23 multiply_pipeline: wgpu::RenderPipeline,
24 atlas: wgpu::Texture,
25 bind_group: wgpu::BindGroup,
26 image_sampler: wgpu::Sampler,
20 layout: wgpu::BindGroupLayout,
21 /// A pipeline for each of `Blend::STATES`.
22 pipelines: [wgpu::RenderPipeline; 4],
23 atlas: (wgpu::Texture, wgpu::BindGroup),
24 nearest: wgpu::Sampler,
25 linear: wgpu::Sampler,
2726 vertex_buffer: wgpu::Buffer,
2827 format: wgpu::TextureFormat,
2928 /// Offscreen pictures for groups, the target's size, and how each is sampled.
30 groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>,
31 groups_size: [u32; 2],
29 groups: Vec<(wgpu::Texture, wgpu::BindGroup)>,
3230}
3331
34#[cfg(not(windows))]
3532impl Renderer {
3633 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
3734 Self::with_gpu(Gpu::new(device, queue, format))
3835 }
3936
37 #[cfg(not(windows))]
4038 pub fn device(&self) -> &wgpu::Device {
4139 &self.gpu.device
4240 }
4341
42 #[cfg(not(windows))]
4443 pub fn queue(&self) -> &wgpu::Queue {
4544 &self.gpu.queue
4645 }
......@@ -53,7 +52,7 @@ impl Gpu {
5352 format: wgpu::TextureFormat,
5453 ) -> Self {
5554 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 {
5756 label: Some("Draw texture"),
5857 entries: &[
5958 wgpu::BindGroupLayoutEntry {
......@@ -76,10 +75,23 @@ impl Gpu {
7675 });
7776 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
7877 label: Some("Draw"),
79 bind_group_layouts: &[Some(&binding_layout)],
78 bind_group_layouts: &[Some(&layout)],
8079 ..Default::default()
8180 });
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]| {
8395 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
8496 label: Some("Draw"),
8597 layout: Some(&pipeline_layout),
......@@ -90,7 +102,7 @@ impl Gpu {
90102 buffers: &[Some(wgpu::VertexBufferLayout {
91103 array_stride: size_of::<Vertex>() as u64,
92104 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,
94106 })],
95107 },
96108 fragment: Some(wgpu::FragmentState {
......@@ -99,7 +111,10 @@ impl Gpu {
99111 compilation_options: Default::default(),
100112 targets: &[Some(wgpu::ColorTargetState {
101113 format,
102 blend: Some(blend),
114 blend: Some(wgpu::BlendState {
115 color: component([color, dst_color]),
116 alpha: component([alpha, dst_alpha]),
117 }),
103118 write_mask: wgpu::ColorWrites::ALL,
104119 })],
105120 }),
......@@ -109,63 +124,33 @@ impl Gpu {
109124 multiview_mask: None,
110125 cache: None,
111126 })
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 },
141127 });
142 let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE);
143128 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
144129 label: Some("Draw vertices"),
145130 size: VERTEX_BUFFER_BYTES,
146131 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
147132 mapped_at_creation: false,
148133 });
149 let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
150 label: Some("Draw image filtering"),
151 mag_filter: wgpu::FilterMode::Linear,
152 min_filter: wgpu::FilterMode::Linear,
153 ..Default::default()
154 });
134 let sampler = |filter| {
135 device.create_sampler(&wgpu::SamplerDescriptor {
136 mag_filter: filter,
137 min_filter: filter,
138 ..Default::default()
139 })
140 };
141 let nearest = sampler(wgpu::FilterMode::Nearest);
142 let atlas = atlas(&device, &layout, &nearest, ATLAS_SIZE);
155143 Self {
144 linear: sampler(wgpu::FilterMode::Linear),
145 nearest,
146 atlas,
156147 device,
157148 queue,
158 pipeline,
159 image_pipeline,
160 erase_pipeline,
161 multiply_pipeline,
162 atlas,
163 bind_group,
164 image_sampler,
149 layout,
150 pipelines,
165151 vertex_buffer,
166152 format,
167153 groups: Vec::new(),
168 groups_size: [0; 2],
169154 }
170155 }
171156
......@@ -178,11 +163,11 @@ impl Gpu {
178163 }
179164
180165 pub(super) fn atlas_side(&self) -> u32 {
181 self.atlas.width()
166 self.atlas.0.width()
182167 }
183168
184169 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);
186171 }
187172
188173 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
......@@ -193,7 +178,7 @@ impl Gpu {
193178 y: origin[1],
194179 z: 0,
195180 },
196 ..self.atlas.as_image_copy()
181 ..self.atlas.0.as_image_copy()
197182 },
198183 rgba,
199184 wgpu::TexelCopyBufferLayout {
......@@ -210,21 +195,12 @@ impl Gpu {
210195 }
211196
212197 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
213 let size = wgpu::Extent3d {
214 width: image.size[0],
215 height: image.size[1],
216 depth_or_array_layers: 1,
217 };
218 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
219 label: Some("Draw image"),
220 size,
221 mip_level_count: 1,
222 sample_count: 1,
223 dimension: wgpu::TextureDimension::D2,
224 format: wgpu::TextureFormat::Rgba8UnormSrgb,
225 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
226 view_formats: &[],
227 });
198 let texture = texture(
199 &self.device,
200 image.size,
201 wgpu::TextureFormat::Rgba8UnormSrgb,
202 wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
203 );
228204 self.queue.write_texture(
229205 texture.as_image_copy(),
230206 image.pixels(),
......@@ -233,66 +209,9 @@ impl Gpu {
233209 bytes_per_row: Some(image.size[0] * 4),
234210 rows_per_image: Some(image.size[1]),
235211 },
236 size,
212 texture.size(),
237213 );
238 Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
239 label: Some("Draw image"),
240 layout: &self.pipeline.get_bind_group_layout(0),
241 entries: &[
242 wgpu::BindGroupEntry {
243 binding: 0,
244 resource: wgpu::BindingResource::TextureView(
245 &texture.create_view(&Default::default()),
246 ),
247 },
248 wgpu::BindGroupEntry {
249 binding: 1,
250 resource: wgpu::BindingResource::Sampler(&self.image_sampler),
251 },
252 ],
253 }))
254 }
255
256 /// An offscreen picture `size` large for each group, kept for later frames.
257 fn group_pictures(&mut self, size: [u32; 2], count: usize) {
258 let gpu = self;
259 if gpu.groups_size != size {
260 gpu.groups.clear();
261 gpu.groups_size = size;
262 }
263 while gpu.groups.len() < count {
264 let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
265 label: Some("Draw group"),
266 size: wgpu::Extent3d {
267 width: size[0],
268 height: size[1],
269 depth_or_array_layers: 1,
270 },
271 mip_level_count: 1,
272 sample_count: 1,
273 dimension: wgpu::TextureDimension::D2,
274 format: gpu.format,
275 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
276 | wgpu::TextureUsages::TEXTURE_BINDING,
277 view_formats: &[],
278 });
279 let view = texture.create_view(&Default::default());
280 let binding = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
281 label: Some("Draw group"),
282 layout: &gpu.pipeline.get_bind_group_layout(0),
283 entries: &[
284 wgpu::BindGroupEntry {
285 binding: 0,
286 resource: wgpu::BindingResource::TextureView(&view),
287 },
288 wgpu::BindGroupEntry {
289 binding: 1,
290 resource: wgpu::BindingResource::Sampler(&gpu.image_sampler),
291 },
292 ],
293 });
294 gpu.groups.push((view, binding));
295 }
214 Image(binding(&self.device, &self.layout, &texture, &self.linear))
296215 }
297216
298217 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared
......@@ -304,30 +223,28 @@ impl Gpu {
304223 size: [u32; 2],
305224 clear: [f32; 4],
306225 ) {
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 }
308243 let gpu = &*self;
309244 gpu.queue
310245 .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices));
311246 let mut encoder = gpu.device.create_command_encoder(&Default::default());
312 let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| {
313 for batch in batches {
314 let [x, y, width, height] = batch.scissor;
315 pass.set_scissor_rect(x, y, width, height);
316 let (pipeline, binding) = match batch.blend {
317 Blend::Over => (&gpu.pipeline, &gpu.bind_group),
318 Blend::Image(id) => {
319 let image: &Image = frame.images[&id].texture.as_ref();
320 (&gpu.image_pipeline, &image.0)
321 }
322 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),
323 Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group),
324 };
325 pass.set_pipeline(pipeline);
326 pass.set_bind_group(0, binding, &[]);
327 pass.draw(batch.vertices.clone(), 0..1);
328 }
329 };
330 let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| {
247 let mut paint = |view: &wgpu::TextureView, clear: [f32; 4], batches: &[Batch]| {
331248 let [r, g, b, a] = clear.map(f64::from);
332249 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
333250 label: Some("Draw"),
......@@ -343,67 +260,102 @@ impl Gpu {
343260 ..Default::default()
344261 });
345262 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 }
347275 };
348 for (group, (view, _)) in frame.groups.iter().zip(&gpu.groups) {
349 let mut pass = begin(&mut encoder, view, [0.0; 4]);
350 draw(&mut pass, &frame.batches[group.batches.clone()]);
276 for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) {
277 paint(&picture.create_view(&Default::default()), [0.0; 4], batches);
351278 }
352 let mut pass = begin(&mut encoder, target, clear);
353 let mut next = 0;
354 for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) {
355 draw(&mut pass, &frame.batches[next..group.batches.start]);
356 pass.set_scissor_rect(0, 0, size[0], size[1]);
357 pass.set_pipeline(&gpu.image_pipeline);
358 pass.set_bind_group(0, binding, &[]);
359 pass.draw(group.composite.clone(), 0..1);
360 next = group.batches.end;
361 }
362 draw(&mut pass, &frame.batches[next..]);
363 drop(pass);
279 paint(target, clear, frame.batches);
364280 gpu.queue.submit([encoder.finish()]);
365281 }
366282}
367283
368/// An empty atlas `side` texels square, and how the pipeline samples it.
369fn atlas(
284fn component([source, destination]: [Factor; 2]) -> wgpu::BlendComponent {
285 let factor = |factor| match factor {
286 Factor::Zero => wgpu::BlendFactor::Zero,
287 Factor::One => wgpu::BlendFactor::One,
288 Factor::SourceAlpha => wgpu::BlendFactor::SrcAlpha,
289 Factor::OneMinusSourceAlpha => wgpu::BlendFactor::OneMinusSrcAlpha,
290 Factor::Destination => wgpu::BlendFactor::Dst,
291 };
292 wgpu::BlendComponent {
293 src_factor: factor(source),
294 dst_factor: factor(destination),
295 operation: wgpu::BlendOperation::Add,
296 }
297}
298
299fn texture(
370300 device: &wgpu::Device,
371 pipeline: &wgpu::RenderPipeline,
372 side: u32,
373) -> (wgpu::Texture, wgpu::BindGroup) {
374 let atlas = device.create_texture(&wgpu::TextureDescriptor {
375 label: Some("Glyph atlas"),
301 [width, height]: [u32; 2],
302 format: wgpu::TextureFormat,
303 usage: wgpu::TextureUsages,
304) -> wgpu::Texture {
305 device.create_texture(&wgpu::TextureDescriptor {
306 label: Some("Draw"),
376307 size: wgpu::Extent3d {
377 width: side,
378 height: side,
308 width,
309 height,
379310 depth_or_array_layers: 1,
380311 },
381312 mip_level_count: 1,
382313 sample_count: 1,
383314 dimension: wgpu::TextureDimension::D2,
384 format: wgpu::TextureFormat::Rgba8UnormSrgb,
385 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
315 format,
316 usage,
386317 view_formats: &[],
387 });
388 let view = atlas.create_view(&Default::default());
389 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
390 mag_filter: wgpu::FilterMode::Nearest,
391 min_filter: wgpu::FilterMode::Nearest,
392 ..Default::default()
393 });
394 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
395 label: Some("Glyph atlas"),
396 layout: &pipeline.get_bind_group_layout(0),
318 })
319}
320
321/// How the pipelines sample `texture` through `sampler`.
322fn binding(
323 device: &wgpu::Device,
324 layout: &wgpu::BindGroupLayout,
325 texture: &wgpu::Texture,
326 sampler: &wgpu::Sampler,
327) -> wgpu::BindGroup {
328 device.create_bind_group(&wgpu::BindGroupDescriptor {
329 label: Some("Draw"),
330 layout,
397331 entries: &[
398332 wgpu::BindGroupEntry {
399333 binding: 0,
400 resource: wgpu::BindingResource::TextureView(&view),
334 resource: wgpu::BindingResource::TextureView(
335 &texture.create_view(&Default::default()),
336 ),
401337 },
402338 wgpu::BindGroupEntry {
403339 binding: 1,
404 resource: wgpu::BindingResource::Sampler(&sampler),
340 resource: wgpu::BindingResource::Sampler(sampler),
405341 },
406342 ],
407 });
408 (atlas, bind_group)
343 })
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)
409361}
crates/mobile/Cargo.toml+2
......@@ -19,6 +19,8 @@ pollster = "0.4"
1919resvg = { version = "0.48.1", default-features = false }
2020serde = { version = "1", features = ["derive"] }
2121serde_json = "1"
22# Clears a password handed in to unlock a section.
23zeroize = "1.9.0"
2224
2325[target.'cfg(target_os = "ios")'.dependencies]
2426objc2 = "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
3333 const char *date, const char *time);
3434char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
3535char *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);
3638char *sb_library_sync_status(const Library *library);
3739void sb_library_set_offline(const Library *library, bool offline);
3840void sb_library_touched(const Library *library, const char *path);
......@@ -56,6 +58,7 @@ char *sb_section_themes(const Section *section, const char *id);
5658bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme);
5759bool sb_section_flush(const Section *section, double seconds);
5860void sb_section_wake(const Section *section);
61void sb_section_set_author(const Section *section, const char *author);
5962void sb_section_free(Section *section);
6063
6164View *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);
6568bool sb_view_frame_pending(const View *view);
6669void sb_view_set_dark(View *view, bool dark);
6770bool 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);
6873bool sb_view_reload(View *view, bool discard);
6974void sb_view_focus(View *view, bool focused);
7075void sb_view_content(View *view, float bounds[4]);
crates/mobile/src/lib.rs+104-55
......@@ -37,7 +37,7 @@ use std::{
3737 ffi::{CStr, CString, c_char, c_void},
3838 mem::ManuallyDrop,
3939 sync::{
40 Arc, Mutex,
40 Arc, LazyLock, Mutex, MutexGuard,
4141 atomic::{AtomicBool, Ordering},
4242 },
4343 task::{Wake, Waker},
......@@ -125,29 +125,19 @@ impl Canvas {
125125 /// The page on `paper` as OneNote 2010 prints it, footers naming `section`.
126126 fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> {
127127 let page = self.page.editor.page().ok()?;
128 let mut engine = ENGINES
129 .lock()
130 .ok()
131 .and_then(|mut engines| engines.pop())
132 .unwrap_or_default();
128 let mut engine = pooled_engine();
133129 let setup = canvas::print::Setup {
134130 paper,
135131 fit_width: true,
136132 footer: canvas::print::Footer::SectionAndPage,
137133 };
138134 let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup);
139 if let Ok(mut engines) = ENGINES.lock() {
140 engines.push(engine);
141 }
135 pool_engine(engine);
142136 pdf.ok()
143137 }
144138
145139 fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> {
146 let mut engine = ENGINES
147 .lock()
148 .ok()
149 .and_then(|mut engines| engines.pop())
150 .unwrap_or_default();
140 let mut engine = pooled_engine();
151141 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
152142 let mut page = PageView::new(
153143 editor,
......@@ -159,7 +149,7 @@ impl Canvas {
159149 );
160150 page.touch = true;
161151 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();
163153 Ok(Self {
164154 page,
165155 space,
......@@ -198,19 +188,7 @@ impl Canvas {
198188 /// What the page took since the last call as one edit, or none when it took nothing.
199189 fn edit(&mut self) -> Result<Option<Edit>> {
200190 let ops = self.page.editor.take_ops()?;
201 if ops.is_empty() {
202 return Ok(None);
203 }
204 Ok(Some(Edit {
205 at: library::filetime(),
206 ops: ops
207 .into_iter()
208 .map(|op| Op::Page {
209 space: self.space,
210 op,
211 })
212 .collect(),
213 }))
191 Ok((!ops.is_empty()).then(|| page_edit(self.space, ops)))
214192 }
215193
216194 fn device(&self, point: [f32; 2]) -> [f32; 2] {
......@@ -633,23 +611,33 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
633611/// rather than enumerating the system's fonts again.
634612static 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
636628/// The reader's Snap To Grid and Default font, which every page opened from then on takes,
637629/// 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) {
641 OPTIONS
642 .lock()
643 .unwrap_or_else(|error| error.into_inner())
644 .clone()
645 .unwrap_or((true, canvas::editor::DefaultFont::default()))
633fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> {
634 OPTIONS.lock().unwrap_or_else(|error| error.into_inner())
646635}
647636
648637/// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid.
649638#[unsafe(no_mangle)]
650639pub extern "C" fn sb_set_snap_to_grid(on: bool) {
651 let font = options().1;
652 *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font));
640 options().0 = on;
653641}
654642
655643/// 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) {
658646/// `face` is NUL-terminated UTF-8.
659647#[unsafe(no_mangle)]
660648pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {
661 let snap = options().0;
662 let font = canvas::editor::DefaultFont {
649 options().1 = canvas::editor::DefaultFont {
663650 face: string(face),
664651 size,
665652 color: None,
666653 };
667 *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font));
668654}
669655
670656pub struct View {
......@@ -676,6 +662,8 @@ pub struct View {
676662 section: Arc<Shared>,
677663 /// A conflict page, which shows what is stored and takes no edits.
678664 read_only: bool,
665 /// The column, in page points, a reading view reflows the page into; it takes no edits.
666 reading: Option<f32>,
679667}
680668
681669impl View {
......@@ -731,13 +719,7 @@ impl View {
731719 .unwrap_or_default();
732720 let restyle = onestore::op::restyle(&page, &sheet)?;
733721 if !read_only && !restyle.is_empty() {
734 section.shared.apply(Edit {
735 at: library::filetime(),
736 ops: restyle
737 .into_iter()
738 .map(|op| Op::Page { space, op })
739 .collect(),
740 })?;
722 section.shared.apply(page_edit(space, restyle))?;
741723 page = section.shared.page(space)?.0;
742724 }
743725 let frame = Arc::<Frame>::default();
......@@ -757,6 +739,7 @@ impl View {
757739 frame,
758740 section: Arc::clone(&section.shared),
759741 read_only,
742 reading: None,
760743 })
761744 }
762745
......@@ -795,7 +778,7 @@ impl View {
795778 fn stored(&mut self, result: Result<bool>) -> bool {
796779 let changed = report(result).unwrap_or(false);
797780 match report(self.canvas.edit()) {
798 Some(Some(_)) if self.read_only => {
781 Some(Some(_)) if self.read_only || self.reading.is_some() => {
799782 let _ = report(self.reload());
800783 }
801784 Some(Some(edit)) => {
......@@ -830,17 +813,54 @@ impl View {
830813 }
831814 let (page, read_only) = self.section.page(self.canvas.space)?;
832815 self.read_only = read_only;
816 if let Some(column) = self.reading {
817 return self.show(page, Some(column));
818 }
833819 Ok(moved(self.canvas.page.refresh(page)?))
834820 }
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 }
835849}
836850
837851impl Drop for View {
838852 fn drop(&mut self) {
839853 // SAFETY: `canvas` is not used again.
840854 let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) };
841 if let Ok(mut engines) = ENGINES.lock() {
842 engines.push(canvas.page.engine);
843 }
855 pool_engine(canvas.page.engine);
856 }
857}
858
859/// What a page took as one edit of the page in `space`, made now.
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(),
844864 }
845865}
846866
......@@ -918,10 +938,39 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {
918938 view.canvas.set_dark(dark);
919939}
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.
922942#[unsafe(no_mangle)]
923943pub extern "C" fn sb_view_read_only(view: &View) -> bool {
924 view.read_only
944 view.read_only || view.reading.is_some()
945}
946
947/// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the
948/// `verdict` saying how it reflows or why not.
949#[unsafe(no_mangle)]
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)
925974}
926975
927976/// Shows the page as stored after a change made elsewhere, keeping the caret; with
......@@ -1234,7 +1283,7 @@ struct Choices {
12341283 templates: Vec<&'static str>,
12351284}
12361285
1237fn srgb(colorref: u32) -> [u8; 3] {
1286pub(crate) fn srgb(colorref: u32) -> [u8; 3] {
12381287 let [red, green, blue, _] = colorref.to_le_bytes();
12391288 [red, green, blue]
12401289}
......@@ -1399,7 +1448,7 @@ pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8
13991448 let Some(date) = view.canvas.page.editor.date() else {
14001449 return -1;
14011450 };
1402 *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600;
1451 *seconds = library::unix(date.timestamp());
14031452 field as i8
14041453}
14051454
crates/mobile/src/library.rs+167-82
......@@ -2,7 +2,7 @@
22//! SMB share through the embedded client. A section opens through its replica in the app's
33//! 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};
66use canvas::search::{Entry, Index, Query, Tagged};
77use notebook::{
88 Remote, Replica,
......@@ -14,6 +14,7 @@ use onestore::{
1414 CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction,
1515 op::{Edit, Op, SectionOp},
1616 page::Page,
17 protected::Key,
1718};
1819use std::{
1920 collections::{BTreeMap, HashMap},
......@@ -249,6 +250,15 @@ pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {
249250 }
250251}
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
252262/// When a section's file was last checked for changes, and how it was then.
253263struct Checked {
254264 at: Instant,
......@@ -267,6 +277,8 @@ pub struct Library {
267277 open: Mutex<Vec<(String, Weak<Shared>)>>,
268278 /// Work Offline, which sections opened later follow too.
269279 offline: AtomicBool,
280 /// Keys of the protected sections unlocked this run, by catalog path; in memory only.
281 keys: Mutex<HashMap<String, Key>>,
270282 /// How long edits wait for a pause in typing before they publish (`Section::set_pause`):
271283 /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent
272284 /// with another device's a conflict version.
......@@ -285,8 +297,12 @@ pub(crate) struct Tab {
285297 pub(crate) group: String,
286298 /// The tab colour in sRGB.
287299 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.
289302 pub(crate) readable: bool,
303 /// Password protected and not unlocked (`sb_library_unlock`).
304 #[serde(default)]
305 pub(crate) locked: bool,
290306 /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it.
291307 #[serde(default)]
292308 pub(crate) downloading: bool,
......@@ -295,9 +311,9 @@ pub(crate) struct Tab {
295311 pub(crate) problem: Option<String>,
296312}
297313
298fn rgb(colorref: u32) -> [u8; 3] {
299 let [red, green, blue, _] = colorref.to_le_bytes();
300 [red, green, blue]
314/// Titles keep OneNote's line breaks, which a one-line list shows as spaces.
315fn one_line(title: &str) -> String {
316 title.replace(|char: char| char.is_control(), " ")
301317}
302318
303319fn stem(path: &str) -> String {
......@@ -309,18 +325,21 @@ fn stem(path: &str) -> String {
309325
310326/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps
311327/// 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>) {
313329 for section in &folder.sections {
314330 let (name, color, readable) = match &section.state {
315331 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),
317334 };
335 let locked = matches!(section.state, SectionState::Locked) && !readable;
318336 tabs.push(Tab {
319337 name: name.unwrap_or_else(|| stem(&section.path)),
320338 path: section.path.clone(),
321339 group: folder.path.clone(),
322 color: rgb(color.unwrap_or(SECTION_COLOR)),
340 color: srgb(color.unwrap_or(SECTION_COLOR)),
323341 readable,
342 locked,
324343 downloading: false,
325344 problem: None,
326345 });
......@@ -330,8 +349,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
330349 name: stem(&entry.path),
331350 path: entry.path.clone(),
332351 group: folder.path.clone(),
333 color: rgb(SECTION_COLOR),
352 color: srgb(SECTION_COLOR),
334353 readable: false,
354 locked: false,
335355 downloading: entry.reason == Reason::Evicted,
336356 problem: match entry.reason {
337357 Reason::InUse => Some("Section in use".into()),
......@@ -342,7 +362,7 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
342362 }
343363 for group in &folder.groups {
344364 if !group.path.ends_with("OneNote_RecycleBin") {
345 self::tabs(group, tabs);
365 self::tabs(group, unlocked, tabs);
346366 }
347367 }
348368}
......@@ -377,6 +397,7 @@ impl Library {
377397 background,
378398 open: Mutex::default(),
379399 offline: AtomicBool::new(false),
400 keys: Default::default(),
380401 pause: if local { Duration::ZERO } else { PAUSE },
381402 })
382403 }
......@@ -399,6 +420,7 @@ impl Library {
399420 background: Some(background),
400421 open: Mutex::default(),
401422 offline: AtomicBool::new(false),
423 keys: Default::default(),
402424 pause: Duration::ZERO,
403425 };
404426 let reached = library.with_notebook(false, |_| Ok(()));
......@@ -501,6 +523,24 @@ impl Library {
501523 change(reopened)
502524 }
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
504544 /// The notebook's sections, read again.
505545 pub(crate) fn tabs(&self) -> Result<Vec<Tab>> {
506546 if let Place::File(file) = &self.place {
......@@ -509,8 +549,9 @@ impl Library {
509549 name: stem(&path),
510550 path,
511551 group: String::new(),
512 color: rgb(SECTION_COLOR),
552 color: srgb(SECTION_COLOR),
513553 readable: true,
554 locked: false,
514555 downloading: false,
515556 problem: None,
516557 }]);
......@@ -521,7 +562,7 @@ impl Library {
521562 tabs: Vec::new(),
522563 files: BTreeMap::new(),
523564 };
524 tabs(notebook.catalog(), &mut listed.tabs);
565 tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs);
525566 files(notebook.catalog(), &mut listed.files);
526567 if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) {
527568 background.watch(notebook.replicas());
......@@ -586,25 +627,36 @@ impl Library {
586627 |file| Ok(Coordinated(file.to_owned())),
587628 notify,
588629 )?),
589 Place::Folder(_) => Ok(self
590 .notebook()
591 .as_ref()
592 .ok_or("A lone section has no notebook")?
593 .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?),
630 Place::Folder(_) => {
631 let notebook = self.notebook();
632 let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?;
633 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
634 Ok(match self.unlocked().get(path) {
635 Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?,
636 None => notebook.section_with(path, connect, notify)?,
637 })
638 }
594639 Place::Share { server, root, .. } => {
595 let file = if root.is_empty() {
596 path.to_owned()
597 } else {
598 format!("{root}/{path}")
640 let file = on_share(root, path);
641 let key = self.unlocked().get(path).cloned();
642 // A protected section's replica is named by the identity its file holds.
643 let cache = match &key {
644 Some(_) => self
645 .notebook()
646 .as_ref()
647 .ok_or("The notebook hasn’t been read")?
648 .replica_path(path)?,
649 None => {
650 let identity = self
651 .listed()
652 .and_then(|mut listed| listed.files.remove(path))
653 .ok_or("The notebook hasn’t listed this section")?;
654 let replicas = self.share_replicas().ok_or("Not a share")?;
655 replicas.join(format!("{identity}.sqlite"))
656 }
599657 };
600 let identity = self
601 .listed()
602 .and_then(|mut listed| listed.files.remove(path))
603 .ok_or("The notebook hasn’t listed this section")?;
604 let replicas = self.share_replicas().ok_or("Not a share")?;
605 let cache = replicas.join(format!("{identity}.sqlite"));
606 std::fs::create_dir_all(&replicas)?;
607 let replica = Replica::open_or_create(&cache, || {
658 std::fs::create_dir_all(cache.parent().ok_or("No cache")?)?;
659 let replica = Replica::open_or_create(&cache, key.as_ref(), || {
608660 let client = self.client().ok_or_else(|| {
609661 std::io::Error::new(
610662 std::io::ErrorKind::NotConnected,
......@@ -642,13 +694,20 @@ pub(crate) enum Status {
642694pub(crate) struct Shared {
643695 pub section: session::Section,
644696 /// Who the edits name, as OneNote names the Office user.
645 pub author: String,
697 pub author: Mutex<String>,
646698 pub status: AtomicU8,
647699}
648700
649701impl Shared {
702 pub fn author(&self) -> String {
703 self.author
704 .lock()
705 .unwrap_or_else(|error| error.into_inner())
706 .clone()
707 }
708
650709 pub fn apply(&self, edit: Edit) -> Result<()> {
651 self.section.apply(&self.author, edit)?;
710 self.section.apply(&self.author(), edit)?;
652711 self.status.store(Status::Saving as u8, Ordering::Relaxed);
653712 Ok(())
654713 }
......@@ -694,7 +753,7 @@ pub(crate) struct Version {
694753 pub(crate) created: Option<i64>,
695754}
696755
697fn unix(filetime: u64) -> i64 {
756pub(crate) fn unix(filetime: u64) -> i64 {
698757 (filetime / 10_000_000) as i64 - 11_644_473_600
699758}
700759
......@@ -709,7 +768,7 @@ pub(crate) fn filetime() -> u64 {
709768/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats
710769/// them: the long date and the short time; titled in the Default font's face.
711770fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
712 let font = crate::options().1;
771 let font = crate::options().1.clone();
713772 Ok(PageCreation::new(None, Some(""), author)?
714773 .titled_in(&font.face, font.color)?
715774 .dated(date, time)?)
......@@ -718,12 +777,8 @@ fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
718777impl Section {
719778 /// The notebook's themes; none for a lone section.
720779 pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes {
721 let notebook = self
722 .library
723 .notebook
724 .lock()
725 .unwrap_or_else(|error| error.into_inner());
726 notebook
780 self.library
781 .notebook()
727782 .as_ref()
728783 .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into)))
729784 .unwrap_or_default()
......@@ -731,12 +786,8 @@ impl Section {
731786
732787 /// Merges `change` into the notebook's themes.
733788 pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> {
734 let notebook = self
735 .library
736 .notebook
737 .lock()
738 .unwrap_or_else(|error| error.into_inner());
739 notebook
789 self.library
790 .notebook()
740791 .as_ref()
741792 .ok_or("A section on its own keeps no themes")?
742793 .save_themes(change)?;
......@@ -772,7 +823,7 @@ impl Section {
772823 ) -> Result<Self> {
773824 let shared = Arc::new(Shared {
774825 section: library.section(path, notify)?,
775 author,
826 author: Mutex::new(author),
776827 status: AtomicU8::new(0),
777828 });
778829 let mut open = library
......@@ -792,8 +843,7 @@ impl Section {
792843 .into_iter()
793844 .map(|(space, title, level)| Row {
794845 id: space.to_string(),
795 // Titles keep OneNote's line breaks, which a one-line list shows as spaces.
796 title: title.replace(|char: char| char.is_control(), " "),
846 title: one_line(&title),
797847 level,
798848 versions: conflicts
799849 .iter()
......@@ -821,7 +871,7 @@ impl Section {
821871 date: &str,
822872 time: &str,
823873 ) -> Result<ExGuid> {
824 let creation = dated(&self.shared.author, date, time)?;
874 let creation = dated(&self.shared.author(), date, time)?;
825875 let space = creation.space();
826876 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
827877 if let Some(parent) = parent {
......@@ -857,14 +907,14 @@ impl Section {
857907 if listed.iter().any(|(page, ..)| *page == space) {
858908 if self.library.notebook().is_some() {
859909 let page = section.page(space)?;
860 let author = &self.shared.author;
910 let author = self.shared.author();
861911 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)?)
863913 })?;
864914 }
865915 if listed.len() == 1 {
866916 ops.push(Op::Section(SectionOp::Create(dated(
867 &self.shared.author,
917 &self.shared.author(),
868918 date,
869919 time,
870920 )?)));
......@@ -958,7 +1008,7 @@ impl Library {
9581008 .map(|found| Found {
9591009 section: found.section,
9601010 page: found.space.to_string(),
961 title: found.title.replace(|char: char| char.is_control(), " "),
1011 title: one_line(&found.title),
9621012 snippet: found.snippet,
9631013 })
9641014 .collect()
......@@ -975,7 +1025,7 @@ impl Library {
9751025 .map(|tagged: Tagged| Tag {
9761026 section: tagged.section,
9771027 page: tagged.space.to_string(),
978 title: tagged.title.replace(|char: char| char.is_control(), " "),
1028 title: one_line(&tagged.title),
9791029 paragraph: tagged.paragraph.to_string(),
9801030 name: tagged.name,
9811031 shape: tagged.shape,
......@@ -1169,11 +1219,7 @@ impl Library {
11691219 let (stamp, read): (Vec<u8>, Reader) = match &self.place {
11701220 Place::Share { root, .. } => {
11711221 let client = self.client().ok_or("Not a share")?;
1172 let file = if root.is_empty() {
1173 path.to_owned()
1174 } else {
1175 format!("{root}/{path}")
1176 };
1222 let file = on_share(root, path);
11771223 let stamp = client.stamp(&file)?;
11781224 let mut key = stamp.header.to_vec();
11791225 key.extend(stamp.length.to_le_bytes());
......@@ -1240,6 +1286,16 @@ fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T {
12401286 }
12411287}
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
12431299pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char {
12441300 report(value.and_then(|value| Ok(serde_json::to_string(&value)?)))
12451301 .map_or(std::ptr::null_mut(), owned)
......@@ -1249,10 +1305,6 @@ pub(crate) fn optional(text: *const c_char) -> Option<String> {
12491305 (!text.is_null()).then(|| string(text))
12501306}
12511307
1252fn space(text: *const c_char) -> Result<ExGuid> {
1253 Ok(string(text).parse()?)
1254}
1255
12561308/// The notebook folder or lone section file at `path`, its section replicas kept in the
12571309/// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this
12581310/// 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(
12671319 local: bool,
12681320 error: *mut *mut c_char,
12691321) -> *const Library {
1270 match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) {
1271 Ok(library) => Arc::into_raw(Arc::new(library)),
1272 Err(cause) => {
1273 failed(cause, error);
1274 std::ptr::null()
1275 }
1276 }
1322 shared(
1323 Library::open(Path::new(&string(path)), Path::new(&string(cache)), local),
1324 error,
1325 )
12771326}
12781327
12791328/// 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(
12951344 error: *mut *mut c_char,
12961345) -> *const Library {
12971346 let server = server(address, share, user, password, domain);
1298 match Library::server(server, &string(root), Path::new(&string(cache))) {
1299 Ok(library) => Arc::into_raw(Arc::new(library)),
1300 Err(cause) => {
1301 failed(cause, error);
1302 std::ptr::null()
1303 }
1304 }
1347 shared(
1348 Library::server(server, &string(root), Path::new(&string(cache))),
1349 error,
1350 )
13051351}
13061352
13071353/// 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(
14191465 json(library.tagged(open.map(|section| (path.as_str(), section))))
14201466}
14211467
1422/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to
1423/// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced`
1424/// (seconds since 1970, or null before the file was reached), `queued` changes and `error`.
1468/// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's
1469/// Protected Section dialog does; false for a wrong password. The key stays in memory until
1470/// `sb_library_lock_all`.
1471///
1472/// # Safety
1473/// `path` and `password` are NUL-terminated UTF-8.
1474#[unsafe(no_mangle)]
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`.
14251494#[unsafe(no_mangle)]
14261495pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {
14271496 json(Ok(library.sync_status()))
......@@ -1735,8 +1804,11 @@ pub unsafe extern "C" fn sb_section_delete_page(
17351804 date: *const c_char,
17361805 time: *const c_char,
17371806) -> bool {
1738 report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time))))
1739 .is_some()
1807 let deleted = string(id)
1808 .parse()
1809 .map_err(Into::into)
1810 .and_then(|space| section.delete_page(space, &string(date), &string(time)));
1811 report(deleted).is_some()
17401812}
17411813
17421814/// 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 {
17461818 section.flush(Duration::from_secs_f64(seconds))
17471819}
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
17491834/// Asks the section to look for changes made elsewhere now, as when the app returns.
17501835#[unsafe(no_mangle)]
17511836pub 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() {
697697 section.shared.apply(edit).unwrap();
698698 let stored = section.shared.page(canvas.space).unwrap().0.date_text();
699699 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 );
701725}
702726
703727/// 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
66
77[features]
88smb = ["dep:smb2", "dep:tokio"]
9protected = ["onestore/protected", "dep:zeroize"]
109
1110[dependencies]
1211onestore = { path = "../onestore" }
......@@ -18,7 +17,14 @@ tempfile = "3"
1817sha2 = "0.11"
1918smb2 = { version = "=0.21.0", optional = true }
2019tokio = { 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
2329[target.'cfg(target_vendor = "apple")'.dependencies]
2430nix = { 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
196196operations and callbacks must have bounded execution times if shutdown latency
197197matters. Credentials belong to the factory, not the cache database.
198198
199Creation refuses existing paths. Opening validates database integrity and replays
199`create` refuses an existing path; `open_or_create(path, key, source)` opens the cache at
200`path`, reading `source` only to make it where there is none, and opens one another thread
201made meanwhile. Opening validates database integrity and replays
200202the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL
201203synchronization and fullfsync, each queried back: every commit is durable, and the only
202204file 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
324326(`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as
325327OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is
326328placed 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 still
329unavailable 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
328332lists for a file gone from its folder gives way to the file an edit gives its name.
329333Edits keep entries in the order their ordering numbers give; a rename or colour keeps
330334the numbers. Every created, renamed or moved file is placed with
......@@ -346,13 +350,34 @@ targets are `None`.
346350replica, and `session::stored_pages(image)` builds its pages with each page's
347351`LastModifiedTime`: the app's search indexes the sections it has not opened this way.
348352
349With the optional `protected` feature, `Notebook::unlock(path, password)`
350reads a `Locked` section as `Unlocked { pages, .. }`, and
351`Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)`
352applies page ops and stores them under the section's key, straight to the file:
353nothing of a protected section is cached or queued, a section written since
354`unlocked` was read refuses the edit, and a wrong password is
355`Error::Protected(PasswordMismatch)`.
353A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
354its key (a wrong password is `Error::Protected(PasswordMismatch)`), and
355`section_unlocked(path, &key, notify)` (`section_unlocked_with`, or
356`Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself)
357opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do,
358each revision sealed under the section's key. The replica keeps none of it in the clear:
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
357382`Section::import_page(page, author)` copies a page, usually read from another
358383section, 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
369394the previous catalog, so an unreachable share never reads as an emptied
370395notebook.
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
372412## Snowbound's folder
373413
374414`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which
375415OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it
376through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a
377dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's
416on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden
417without storing it), `SetFileAttributesW` on Windows, or macOS's
378418`UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
379419without the folder is whole.
380420
......@@ -475,8 +515,7 @@ probing the connection, so an idle watch sends nothing. `changed` hears each bat
475515paths relative to the directory, `""` when the server lost count, and an error when the watch
476516ends with its connection.
477517
478`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
479share handle that excludes writes and deletion. Empty files succeed; limits,
518An external payload on a share (`discover::Smb`) is read under a read-only share handle that excludes writes and deletion. Empty files succeed; limits,
480519interrupted reads and failed close never return partial bytes. This payload read
481520does 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>> {
3939 let bytes = onestore::read_file(&source_path)?;
4040 let store = Store::parse(&bytes)?;
4141 let index = RevisionIndex::parse(&store)?;
42 #[cfg(feature = "protected")]
4342 let unlocked = if let Some(path) = &password_file {
4443 use std::io::Read;
4544 let mut password = zeroize::Zeroizing::new(String::new());
......@@ -57,20 +56,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
5756 } else {
5857 None
5958 };
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")]
6859 let document = match &unlocked {
6960 Some(section) => section.document()?,
7061 None => Document::parse(&index)?,
7162 };
72 #[cfg(not(feature = "protected"))]
73 let document = Document::parse(&index)?;
7463 if let Some(destination) = destination {
7564 #[cfg(unix)]
7665 {
crates/notebook/examples/keystroke_probe.rs+1
......@@ -135,6 +135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
135135 identity: None,
136136 created: None,
137137 margin_origin: [0.0; 2],
138 rtl: false,
138139 color: None,
139140 rule_lines: None,
140141 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>> {
4444 identity: None,
4545 created: None,
4646 margin_origin: [0.0; 2],
47 rtl: false,
4748 color: None,
4849 rule_lines: None,
4950 objects: vec![PageObject::Outline(Outline {
crates/notebook/examples/save_probe.rs+1
......@@ -50,6 +50,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
5050 identity: None,
5151 created: None,
5252 margin_origin: [0.0; 2],
53 rtl: false,
5354 color: None,
5455 rule_lines: None,
5556 objects: vec![PageObject::Outline(Outline {
crates/notebook/src/background.rs+11-6
......@@ -30,8 +30,6 @@ const STAGGER: Duration = Duration::from_millis(100);
3030/// How long a reported change settles before its section is checked, so the writes of one
3131/// commit cost one check.
3232const SETTLE: Duration = Duration::from_secs(1);
33/// The most entries a folder listing takes.
34const ENTRIES: usize = 100_000;
3533
3634/// Keeps each watched section in sync: a section is checked when a watch on the notebook's
3735/// folder reports it changed, when it could not be reached, and otherwise every interval.
......@@ -66,6 +64,7 @@ pub(crate) struct Shared {
6664}
6765
6866/// 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))]
6968pub(crate) struct Reports {
7069 shared: Weak<Shared>,
7170 connection: u64,
......@@ -338,7 +337,8 @@ impl Background {
338337 let root = root.clone();
339338 let list = move |folder: &str| {
340339 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)
342342 };
343343 Ok(((bind, list), reported))
344344 },
......@@ -361,6 +361,7 @@ impl Background {
361361 pub fn hold(&self, path: &str, section: &Section) {
362362 let replica = section.replica();
363363 let Some(worker) = replica
364 .section
364365 .worker
365366 .lock()
366367 .ok()
......@@ -487,6 +488,7 @@ impl Shared {
487488 }
488489}
489490
491#[cfg_attr(not(feature = "smb"), allow(dead_code))]
490492impl Reports {
491493 /// The sections at or below these paths changed.
492494 pub(crate) fn touched(&self, paths: &[String]) {
......@@ -741,7 +743,10 @@ fn list(
741743
742744/// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it.
743745fn 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 ) {
745750 for suffix in ["-wal", "-shm", ""] {
746751 let mut file = replica.as_os_str().to_owned();
747752 file.push(suffix);
......@@ -837,7 +842,7 @@ fn step<R: Remote>(
837842 if let Some(folder) = replica.parent() {
838843 std::fs::create_dir_all(folder)?;
839844 }
840 Replica::seed(replica, &image)?;
845 Replica::seed(replica, &image, None)?;
841846 Ok(Stamp::of(&image)?)
842847 })();
843848 return match copied {
......@@ -854,7 +859,7 @@ fn step<R: Remote>(
854859 Ok(versions) => versions,
855860 Err(error) => return (None, Err(Error::RemoteIo(error))),
856861 };
857 match crate::peek(replica) {
862 match crate::closed(replica).and_then(|held| crate::peek(&held)) {
858863 Ok((base, 0)) if base == stamp && versions.is_empty() => {
859864 return (Some(0), Ok((stamp, moved)));
860865 }
crates/notebook/src/base.rs+14
......@@ -28,6 +28,20 @@ fn damaged() -> crate::Error {
2828 io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image").into()
2929}
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
3145/// The whole image, or `None` when none is stored.
3246pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> {
3347 let mut query = connection.prepare_cached(&format!(
crates/notebook/src/discover.rs+40-19
......@@ -50,10 +50,30 @@ pub struct Folder {
5050 pub toc: Option<Toc>,
5151 pub sections: Vec<Section>,
5252 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>,
5356 /// Child section files and groups that could not be read.
5457 pub unavailable: Vec<Unavailable>,
5558}
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
5777/// A section file or group folder denied or gone while listing; each discovery tries it again.
5878#[derive(Debug, Serialize)]
5979pub struct Unavailable {
......@@ -346,6 +366,7 @@ fn scan(
346366 toc: None,
347367 sections: Vec::new(),
348368 groups: Vec::new(),
369 order: Vec::new(),
349370 unavailable: Vec::new(),
350371 };
351372 for entry in &listing {
......@@ -551,26 +572,26 @@ fn scan(
551572 .flat_map(|toc| &toc.unresolved)
552573 .map(|entry| (entry.file, entry.order))
553574 .collect();
554 result.sections.sort_by(|a, b| {
555 (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path)
556 .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path))
557 });
558 result.groups.sort_by(|a, b| {
559 (
560 a.toc
561 .as_ref()
562 .and_then(|toc| order.get(&toc.file_id).copied())
563 .unwrap_or(u32::MAX),
564 &a.path,
575 let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied());
576 let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone());
577 result
578 .sections
579 .sort_by_cached_key(|section| ranked(Some(section.file_id), &section.path));
580 let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id);
581 result
582 .groups
583 .sort_by_cached_key(|group| ranked(group_file(group), &group.path));
584 let mut entries: Vec<(u32, String)> = (result.sections.iter())
585 .map(|section| ranked(Some(section.file_id), &section.path))
586 .chain(
587 result
588 .groups
589 .iter()
590 .map(|group| ranked(group_file(group), &group.path)),
565591 )
566 .cmp(&(
567 b.toc
568 .as_ref()
569 .and_then(|toc| order.get(&toc.file_id).copied())
570 .unwrap_or(u32::MAX),
571 &b.path,
572 ))
573 });
592 .collect();
593 entries.sort();
594 result.order = entries.into_iter().map(|(_, path)| path).collect();
574595 let listed = |file: [u8; 16], path: &str| {
575596 let name = path.rsplit('/').next().unwrap_or(path);
576597 result
crates/notebook/src/discover/source.rs+5-1
......@@ -194,6 +194,10 @@ impl Source for Smb<'_> {
194194 return None;
195195 }
196196 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()
198202 }
199203}
crates/notebook/src/lib.rs+71-45
......@@ -10,6 +10,7 @@ use onestore::{
1010 ExGuid,
1111 op::{Edit, OpError},
1212 page::Page,
13 protected::Key,
1314};
1415use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
1516use std::{
......@@ -26,6 +27,7 @@ mod base;
2627pub mod location;
2728mod merge;
2829mod migrate;
30pub mod package;
2931mod queue;
3032mod recovery;
3133mod resolve;
......@@ -61,7 +63,7 @@ pub enum Error {
6163 Rejected(#[from] OpError),
6264 #[error("External payload identity now refers to different bytes")]
6365 AssetChanged,
64 #[cfg(feature = "protected")]
66 /// A password-protected section's password did not match, or its format is unknown.
6567 #[error(transparent)]
6668 Protected(#[from] onestore::protected::Error),
6769}
......@@ -98,10 +100,8 @@ pub enum Resolution {
98100/// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's
99101/// exclusive connection retains ownership between local transactions.
100102pub struct Replica {
101 connection: Arc<Mutex<Connection>>,
102103 section: Arc<working::Thread>,
103104 synchronization: Mutex<()>,
104 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
105105 /// The section's root object space, which names the document.
106106 root: ExGuid,
107107 /// Last, so that it runs once the fields above have closed the cache.
......@@ -124,30 +124,31 @@ impl Replica {
124124 /// Seeds a new cache from a validated section image, refusing any existing path.
125125 /// An initialization error preserves the created file for inspection.
126126 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
127 Self::seed(path.as_ref(), source)?;
128 Self::start(cache_connection(path.as_ref())?)
127 Self::seed(path.as_ref(), source, None)?;
128 Self::start(cache_connection(path.as_ref())?, None)
129129 }
130130
131 /// Opens the cache at `path`, first creating it from the image `source` reads where there
132 /// is none. A cache another thread creates meanwhile, as the background makes an offline
133 /// copy, is opened instead.
131 /// Opens the cache at `path`, a protected section's under `key`, first creating it from
132 /// the image `source` reads where there is none. A cache another thread creates meanwhile,
133 /// as the background makes an offline copy, is opened instead.
134134 pub fn open_or_create(
135135 path: impl AsRef<Path>,
136 key: Option<&Key>,
136137 source: impl FnOnce() -> Result<Vec<u8>>,
137138 ) -> Result<Self> {
138139 let path = path.as_ref();
139140 if !path.exists() {
140 match Self::seed(path, &source()?) {
141 match Self::seed(path, &source()?, key) {
141142 Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
142143 seeded => seeded?,
143144 }
144145 }
145 Self::open(path)
146 Self::open_with(path, key.cloned())
146147 }
147148
148149 /// `create` without opening the cache it made, as for an offline copy.
149 pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> {
150 validate(source)?;
150 pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> {
151 validate(source, key)?;
151152 // Built under a name of its own and linked into place whole, so that a cache that
152153 // exists is complete, and two threads making the same one never share a build.
153154 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
......@@ -201,7 +202,10 @@ impl Replica {
201202 /// a conversion that cannot reproduce every queued page leaves it untouched.
202203 /// Unrecognized databases and unsupported journal modes are rejected.
203204 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> {
205209 let mut connection = cache_connection(path)?;
206210 let application: u32 =
207211 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
......@@ -233,25 +237,21 @@ impl Replica {
233237 }
234238 }
235239 write_ahead(&connection)?;
236 Self::start(connection)
240 Self::start(connection, key)
237241 }
238242
239 fn start(connection: Connection) -> Result<Self> {
240 let connection = Arc::new(Mutex::new(connection));
241 let worker = Arc::new(Mutex::new(std::sync::Weak::new()));
242 let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?;
243 fn start(connection: Connection, key: Option<Key>) -> Result<Self> {
244 let (section, root) = working::spawn(connection, key)?;
243245 Ok(Self {
244 connection,
245246 section,
246247 synchronization: Mutex::new(()),
247 worker,
248248 root,
249249 released: Released::default(),
250250 })
251251 }
252252
253253 fn lock(&self) -> Result<MutexGuard<'_, Connection>> {
254 lock(&self.connection)
254 lock(&self.section.connection)
255255 }
256256
257257 /// Hands `request` to the section thread.
......@@ -329,9 +329,9 @@ impl Replica {
329329
330330 /// The section image the queued edits leave, the unsealed ones sealed as one more
331331 /// 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>> {
333333 self.written()?;
334 working::image(&*self.lock()?)
334 working::image(&*self.lock()?, self.section.key.as_ref())
335335 }
336336
337337 /// Waits until the edits applied before it are written to the queue, as reads answer
......@@ -342,18 +342,51 @@ impl Replica {
342342
343343 /// The section file's identity, which internal links name as `section-id`.
344344 pub fn identity(&self) -> Result<[u8; 16]> {
345 let stamp = base::stamp(&*self.lock()?, base::Image::Base)?
346 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?;
345 let stamp = base::base_stamp(&*self.lock()?)?;
347346 Ok(onestore::Header::parse(&stamp.header)?.file_id)
348347 }
349348
350349 /// Queued edits, oldest first.
351350 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)
353386 }
354387
355388 fn wake_sync(&self) {
356 wake(&self.worker);
389 wake(&self.section.worker);
357390 }
358391}
359392
......@@ -386,10 +419,14 @@ fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) {
386419 }
387420}
388421
389/// Fully validates a section image, returning its root object space.
390fn validate(source: &[u8]) -> Result<ExGuid> {
422/// Fully validates a section image, a protected one under `key`, returning its root object
423/// space.
424fn validate(source: &[u8], key: Option<&Key>) -> Result<ExGuid> {
391425 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 })
393430}
394431
395432/// A closed cache, without opening the replica.
......@@ -405,25 +442,14 @@ fn closed(path: &Path) -> Result<Connection> {
405442}
406443
407444/// A closed cache's base stamp and how many edits wait.
408fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
409 let connection = closed(path)?;
410 let base = base::stamp(&connection, base::Image::Base)?
411 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
445fn peek(connection: &Connection) -> Result<(onestore::Stamp, u64)> {
446 let base = base::base_stamp(connection)?;
412447 let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?;
413448 Ok((base, unsigned(queued)?))
414449}
415450
416/// A closed cache's base image, if its stamp is `stamp`: the section file as it stands.
417fn copied(path: &Path, stamp: &onestore::Stamp) -> Result<Option<Vec<u8>>> {
418 let connection = closed(path)?;
419 if base::stamp(&connection, base::Image::Base)?.as_ref() != Some(stamp) {
420 return Ok(None);
421 }
422 base::read(&connection, base::Image::Base)
423}
424
425fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
426 queue::load(connection, None)
451fn pending(connection: &Connection, key: Option<&Key>) -> Result<Vec<PendingEdit>> {
452 queue::load(connection, key, None)
427453}
428454
429455fn unsigned(value: i64) -> Result<u64> {
crates/notebook/src/location.rs+1-1
......@@ -110,7 +110,7 @@ pub(crate) fn claim(
110110 if !replica.exists() {
111111 continue;
112112 }
113 let adoptable = match crate::peek(&replica) {
113 let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) {
114114 Ok((base, _)) => {
115115 legacy || stamp(identity).is_some_and(|file| follows(&file, &base))
116116 }
crates/notebook/src/migrate.rs+1
......@@ -314,6 +314,7 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
314314 }
315315 queue::insert(
316316 transaction,
317 None,
317318 Some(crate::unsigned(edit.id)?),
318319 current,
319320 &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 @@
11//! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes
22//! 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
49use crate::{PendingEdit, Result, signed, unsigned};
10use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
11use hmac::{Hmac, Mac};
512use onestore::{
613 op::{Edit, Op, PageOp, SectionOp, TableEdit},
714 page::{PageObject, PageParagraph, ParagraphContent, TableCell},
15 protected::Key,
816};
917use rusqlite::{Connection, OptionalExtension, params};
1018use sha2::{Digest, Sha256};
1119use std::{io, sync::Arc};
20use zeroize::Zeroizing;
1221
1322type Payload = Option<Arc<[u8]>>;
1423
......@@ -92,16 +101,79 @@ fn damaged(message: &'static str) -> crate::Error {
92101 io::Error::new(io::ErrorKind::InvalidData, message).into()
93102}
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;
96167/// returns the edit's id.
97168pub(crate) fn insert(
98169 connection: &Connection,
170 key: Option<&Key>,
99171 id: Option<u64>,
100172 batch: i64,
101173 author: &str,
102174 edit: &Edit,
103175) -> Result<u64> {
104 let (text, names) = encode(connection, edit)?;
176 let (text, names) = encode(connection, key, edit)?;
105177 connection.execute(
106178 "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)",
107179 params![id.map(signed).transpose()?, batch, author, text, names],
......@@ -114,8 +186,13 @@ fn hex(digest: &[u8]) -> String {
114186}
115187
116188/// Replaces a stored edit's ops.
117pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> {
118 let (text, names) = encode(connection, edit)?;
189pub(crate) fn rewrite(
190 connection: &Connection,
191 key: Option<&Key>,
192 id: u64,
193 edit: &Edit,
194) -> Result<()> {
195 let (text, names) = encode(connection, key, edit)?;
119196 connection.execute(
120197 "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3",
121198 params![text, names, signed(id)?],
......@@ -123,26 +200,38 @@ pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<(
123200 Ok(())
124201}
125202
126/// The serialized edit without payload bytes and the payload names, storing the payloads.
127fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String>)> {
203/// The serialized edit without payload bytes and the payload names, storing the payloads;
204/// sealed under `key` for a protected section.
205fn encode(
206 connection: &Connection,
207 key: Option<&Key>,
208 edit: &Edit,
209) -> Result<(String, Option<String>)> {
128210 let mut edit = edit.clone();
129211 let mut names: Vec<Option<String>> = Vec::new();
130212 let mut failure = None;
131213 slots(&mut edit, |payload| {
132214 let name = payload.take().map(|bytes| {
133 let digest = Sha256::digest(&bytes);
134 if let Err(error) = connection.execute(
135 "INSERT INTO payloads(sha256, bytes) VALUES (?1, ?2) ON CONFLICT DO NOTHING",
136 params![&digest[..], &bytes[..]],
137 ) {
215 let name = name(key, &bytes);
216 let stored = match key {
217 Some(key) => seal(key, b"payload", &bytes),
218 None => Ok(bytes.to_vec()),
219 };
220 let stored = stored.and_then(|stored| {
221 Ok(connection.execute(
222 "INSERT INTO payloads(sha256, bytes) VALUES (unhex(?1), ?2) ON CONFLICT DO NOTHING",
223 params![&name, &stored[..]],
224 )?)
225 });
226 if let Err(error) = stored {
138227 failure.get_or_insert(error);
139228 }
140 hex(&digest)
229 name
141230 });
142231 names.push(name);
143232 });
144233 if let Some(error) = failure {
145 return Err(error.into());
234 return Err(error);
146235 }
147236 let names = names
148237 .iter()
......@@ -150,15 +239,39 @@ fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String
150239 .then(|| serde_json::to_string(&names))
151240 .transpose()
152241 .map_err(io::Error::other)?;
153 Ok((
154 serde_json::to_string(&edit).map_err(io::Error::other)?,
155 names,
156 ))
242 let text = Zeroizing::new(serde_json::to_string(&edit).map_err(io::Error::other)?);
243 let text = match key {
244 Some(key) => {
245 use base64::Engine;
246 base64::engine::general_purpose::STANDARD.encode(seal(key, b"edit", text.as_bytes())?)
247 }
248 None => text.to_string(),
249 };
250 Ok((text, names))
157251}
158252
159fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result<Edit> {
160 let mut edit: Edit = serde_json::from_str(text)
161 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
253/// A stored edit's ops, without its payloads.
254pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result<Edit> {
255 Ok(match key {
256 Some(key) => {
257 use base64::Engine;
258 let sealed = base64::engine::general_purpose::STANDARD
259 .decode(text)
260 .map_err(|_| damaged("A sealed queued edit is damaged"))?;
261 serde_json::from_slice(&open(key, b"edit", &sealed)?)
262 }
263 None => serde_json::from_str(text),
264 }
265 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?)
266}
267
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)?;
162275 let Some(names) = names else {
163276 return Ok(edit);
164277 };
......@@ -176,18 +289,24 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result<
176289 let Some(name) = name else {
177290 return;
178291 };
179 match query
292 let bytes = query
180293 .query_row([&name], |row| row.get::<_, Vec<u8>>(0))
181 .optional()
182 {
183 Ok(Some(bytes)) if hex(&Sha256::digest(&bytes)) == name => {
294 .optional();
295 let bytes = match (key, bytes) {
296 (Some(key), Ok(Some(sealed))) => {
297 open(key, b"payload", &sealed).map(|bytes| Some(bytes.to_vec()))
298 }
299 (_, bytes) => bytes.map_err(Into::into),
300 };
301 match bytes {
302 Ok(Some(bytes)) if self::name(key, &bytes) == name => {
184303 *payload = Some(Arc::from(bytes));
185304 }
186305 Ok(_) => {
187306 failure.get_or_insert(damaged("A queued payload is missing or damaged"));
188307 }
189308 Err(error) => {
190 failure.get_or_insert(error.into());
309 failure.get_or_insert(error);
191310 }
192311 }
193312 });
......@@ -201,7 +320,11 @@ fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result<
201320}
202321
203322/// 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>> {
205328 let mut query = connection.prepare_cached(
206329 "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id",
207330 )?;
......@@ -211,7 +334,7 @@ pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<Pe
211334 edits.push(PendingEdit {
212335 id: unsigned(row.get(0)?)?,
213336 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)?)?,
215338 });
216339 }
217340 Ok(edits)
......@@ -304,7 +427,7 @@ mod tests {
304427 })
305428 .collect(),
306429 };
307 let id = insert(&connection, None, 1, "Author", &edit).unwrap();
430 let id = insert(&connection, None, None, 1, "Author", &edit).unwrap();
308431 let stored: String = connection
309432 .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| {
310433 row.get(0)
......@@ -315,7 +438,7 @@ mod tests {
315438 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
316439 .unwrap();
317440 assert_eq!(payloads, 1);
318 let loaded = load(&connection, None).unwrap();
441 let loaded = load(&connection, None, None).unwrap();
319442 assert_eq!(loaded[0].edit, edit);
320443 let Op::Page {
321444 op:
crates/notebook/src/recovery.rs+18-15
......@@ -21,11 +21,22 @@ pub struct RecoverySummary {
2121/// Read-only recovery evidence; it cannot publish or acknowledge an edit.
2222pub struct Recovery {
2323 connection: Connection,
24 /// A password-protected section's key, which opens its sealed queue.
25 key: Option<Key>,
2426}
2527
2628impl Recovery {
2729 /// Opens an exported archive without migration or conversion into a writable replica.
2830 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> {
2940 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
3041 connection.busy_timeout(Duration::ZERO)?;
3142 connection.execute_batch("BEGIN")?;
......@@ -48,10 +59,10 @@ impl Recovery {
4859 )
4960 .into());
5061 }
51 let recovery = Self { connection };
62 let recovery = Self { connection, key };
5263 recovery.snapshot()?;
53 for edit in pending(&recovery.connection)? {
54 sync::status(&recovery.connection, edit.id)?;
64 for edit in recovery.pending()? {
65 recovery.status(edit.id)?;
5566 }
5667 receipts(&recovery.connection)?;
5768 Ok(recovery)
......@@ -63,31 +74,23 @@ impl Recovery {
6374
6475 /// The image the archived queue leaves, as `Replica::snapshot` gives it.
6576 pub fn snapshot(&self) -> Result<Vec<u8>> {
66 working::image(&self.connection)
67 }
68
69 fn base(&self) -> Result<Vec<u8>> {
70 Ok(
71 base::read(&self.connection, base::Image::Base)?.ok_or_else(|| {
72 io::Error::new(io::ErrorKind::InvalidData, "The archive has no base image")
73 })?,
74 )
77 working::image(&self.connection, self.key.as_ref())
7578 }
7679
7780 /// The last observed remote image.
7881 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
7982 match base::read(&self.connection, base::Image::Remote)? {
8083 Some(image) => Ok(image),
81 None => self.base(),
84 None => base::base(&self.connection),
8285 }
8386 }
8487
8588 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
86 pending(&self.connection)
89 pending(&self.connection, self.key.as_ref())
8790 }
8891
8992 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())
9194 }
9295
9396 pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> {
crates/notebook/src/session.rs+334-118
......@@ -10,9 +10,10 @@ use onestore::{
1010 document::Document,
1111 op::{Edit, Op, SectionOp},
1212 page::Page,
13 protected,
1314};
1415use std::{
15 collections::BTreeMap,
16 collections::{BTreeMap, BTreeSet},
1617 io,
1718 io::Write,
1819 path::{Path, PathBuf},
......@@ -99,14 +100,9 @@ pub trait Storage: Send + Sync {
99100 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>;
100101 /// Publishes a transaction made on the file's current image.
101102 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>;
102}
103
104/// A password-protected section as `Notebook::unlock` read it.
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>,
103 /// Puts the file `with` in the place of the section or TOC at `path` under the coordination
104 /// its writers take, provided `path` still has `base`'s stamp.
105 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>;
110106}
111107
112108/// A mounted notebook directory.
......@@ -235,6 +231,14 @@ impl Storage for Directory {
235231 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {
236232 Ok(transaction.commit_file(self.path(path))?)
237233 }
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 }
238242}
239243
240244/// A notebook directory on an SMB share, reached through the native-compatible client.
......@@ -275,7 +279,7 @@ impl Storage for Share {
275279 fn exists(&self, path: &str) -> bool {
276280 let (folder, name) = split(path);
277281 self.client
278 .read_dir(&self.path(folder), 100_000)
282 .read_dir(&self.path(folder), LIMITS.entries)
279283 .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name))
280284 }
281285
......@@ -322,9 +326,15 @@ impl Storage for Share {
322326 .client
323327 .commit_transaction(&self.path(path), transaction)?)
324328 }
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 }
325335}
326336
327const LIMITS: discover::Limits = discover::Limits {
337pub(crate) const LIMITS: discover::Limits = discover::Limits {
328338 entries: 100_000,
329339 bytes_per_file: 256 * 1024 * 1024,
330340 depth: 64,
......@@ -415,20 +425,16 @@ impl Notebook {
415425 let catalog = storage.discover(&mut read, LIMITS)?;
416426 let location = storage.location();
417427 let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new();
418 let mut folders = vec![&catalog];
419 while let Some(folder) = folders.pop() {
420 for section in &folder.sections {
421 let identity = match (&root, &section.state) {
422 (Some(_), discover::SectionState::Readable { document, .. }) => *document,
423 (None, discover::SectionState::Readable { .. }) => section.file_id,
424 _ => continue,
425 };
426 claims
427 .entry(replica_location(&location, section))
428 .or_default()
429 .push((identity, &section.path));
430 }
431 folders.extend(&folder.groups);
428 for section in catalog.sections() {
429 let identity = match (&root, &section.state) {
430 (Some(_), discover::SectionState::Readable { document, .. }) => *document,
431 (None, discover::SectionState::Readable { .. }) => section.file_id,
432 _ => continue,
433 };
434 claims
435 .entry(replica_location(&location, section))
436 .or_default()
437 .push((identity, &section.path));
432438 }
433439 for (at, sections) in &claims {
434440 let identities: Vec<[u8; 16]> =
......@@ -447,6 +453,7 @@ impl Notebook {
447453 listing,
448454 };
449455 notebook.keep_listing();
456 notebook.forget_superseded();
450457 Ok(notebook)
451458 }
452459
......@@ -545,30 +552,23 @@ impl Notebook {
545552 }
546553 }
547554 self.catalog = catalog;
555 self.forget_superseded();
548556 Ok(changes)
549557 }
550558
551559 fn folder(&self, path: &str) -> Result<&discover::Folder> {
552 let mut folders = vec![&self.catalog];
553 while let Some(folder) = folders.pop() {
554 if folder.path == path {
555 return Ok(folder);
556 }
557 folders.extend(&folder.groups);
558 }
559 Err(io::Error::from(io::ErrorKind::NotFound).into())
560 self.catalog
561 .folders()
562 .find(|folder| folder.path == path)
563 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into())
560564 }
561565
562566 /// A catalog section's path, refusing paths the catalog does not list.
563567 fn section_path(&self, path: &str) -> Result<&discover::Section> {
564 let mut folders = vec![&self.catalog];
565 while let Some(folder) = folders.pop() {
566 if let Some(section) = folder.sections.iter().find(|section| section.path == path) {
567 return Ok(section);
568 }
569 folders.extend(&folder.groups);
570 }
571 Err(io::Error::from(io::ErrorKind::NotFound).into())
568 self.catalog
569 .sections()
570 .find(|section| section.path == path)
571 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into())
572572 }
573573
574574 /// A folder's TOC path and file identity, creating the TOC when the folder has none
......@@ -1051,6 +1051,56 @@ impl Notebook {
10511051 Ok(purged)
10521052 }
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
10541104 /// Moves a section or section group into the folder at catalog path `folder`, last, as
10551105 /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's
10561106 /// entry follows it. Returns its new catalog path.
......@@ -1133,14 +1183,11 @@ impl Notebook {
11331183 let Some(link) = onestore::page::link::parse_internal_link(url) else {
11341184 return Ok(None);
11351185 };
1136 let mut sections = Vec::new();
1137 let mut folders = vec![&self.catalog];
1138 while let Some(folder) = folders.pop() {
1139 sections.extend(folder.sections.iter().filter(|section| {
1140 matches!(section.state, discover::SectionState::Readable { .. })
1141 }));
1142 folders.extend(&folder.groups);
1143 }
1186 let mut sections: Vec<_> = self
1187 .catalog
1188 .sections()
1189 .filter(|section| matches!(section.state, discover::SectionState::Readable { .. }))
1190 .collect();
11441191 sections.sort_by_key(|section| section.file_id != link.section);
11451192 let Some(page) = link.page else {
11461193 return Ok(sections
......@@ -1168,56 +1215,172 @@ impl Notebook {
11681215 self.storage.read(&self.section_path(path)?.path)
11691216 }
11701217
1171 /// The pages of a password-protected section: nothing is cached, and the decoded
1172 /// buffers go when the pages have been built.
1173 #[cfg(feature = "protected")]
1174 pub fn unlock(&self, path: &str, password: &str) -> Result<Unlocked> {
1175 let path = self.section_path(path)?.path.clone();
1176 let snapshot = self.storage.read(&path)?;
1177 let pages = {
1178 let store = Store::parse(&snapshot)?;
1179 let index = RevisionIndex::parse(&store)?;
1180 let unlocked = onestore::protected::UnlockedSection::open(
1181 &index,
1182 password,
1183 onestore::protected::Limits::default(),
1184 )?;
1185 let document = unlocked.document()?;
1186 document
1187 .pages()?
1188 .into_iter()
1189 .map(|(space, _)| Ok((space, Page::from_space(&document, space)?)))
1190 .collect::<Result<_>>()?
1191 };
1192 Ok(Unlocked { pages, snapshot })
1218 /// The key of the password-protected section at catalog `path`, opened with `password`.
1219 /// Edits this device queued before the section was protected elsewhere, held since, are
1220 /// queued again under the key: each page they changed comes back as a copy, as a page
1221 /// another client removed does, the section's pages having taken new identities.
1222 pub fn unlock(&self, path: &str, password: &str) -> Result<protected::Key> {
1223 let section = self.section_path(path)?;
1224 let image = self.storage.read(&section.path)?;
1225 let key = protected::Key::open(&image, password)?;
1226 let held: Vec<PathBuf> = self
1227 .superseded(&[section])
1228 .into_iter()
1229 .filter(|(.., queued)| *queued > 0)
1230 .map(|(replica, ..)| replica)
1231 .collect();
1232 if !held.is_empty() {
1233 let replica =
1234 Replica::open_or_create(self.replica_path(path)?, Some(&key), || Ok(image))?;
1235 for path in held {
1236 // A queue sealed under an earlier password waits for that password.
1237 let Ok(old) = Replica::open(&path) else {
1238 continue;
1239 };
1240 for (author, edit) in old.copies()? {
1241 replica.apply(&author, edit)?;
1242 }
1243 drop(old);
1244 remove_replica(&path)?;
1245 }
1246 }
1247 Ok(key)
11931248 }
11941249
1195 /// Applies `edit` to pages of `unlocked` and stores it under the section's key, straight
1196 /// to the file: nothing of a protected section is cached or queued. A section written
1197 /// since `unlocked` was read refuses the edit; unlock again for its pages.
1198 #[cfg(feature = "protected")]
1199 pub fn apply_unlocked(
1250 /// The replicas of the files the protected `locked` sections superseded when their
1251 /// passwords were set (`onestore::protected::rekey` keeps the placement a file's header
1252 /// names), each held so that no one opens it meanwhile, with how many edits it queues. Only
1253 /// replicas no readable section names are read; one in use is left.
1254 fn superseded(
12001255 &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,
12011333 path: &str,
1202 password: &str,
1203 unlocked: &mut Unlocked,
1204 author: &str,
1205 edit: &Edit,
1206 ) -> Result<()> {
1207 let path = self.section_path(path)?.path.clone();
1208 let transaction = {
1209 let store = Store::parse(&unlocked.snapshot)?;
1210 let index = RevisionIndex::parse(&store)?;
1211 onestore::protected::UnlockedSection::open(
1212 &index,
1213 password,
1214 onestore::protected::Limits::default(),
1215 )?
1216 .apply(author, edit)?
1217 };
1218 self.storage.commit(&path, &transaction)?;
1219 transaction.apply(&mut unlocked.snapshot)?;
1220 Ok(())
1334 key: Option<&protected::Key>,
1335 password: Option<&str>,
1336 ) -> Result<Option<protected::Key>> {
1337 let section = self.section_path(path)?;
1338 let replica = self.replica_path(path)?;
1339 // Held until the file is replaced, so that no session queues edits to the old file.
1340 let held = replica
1341 .exists()
1342 .then(|| crate::closed(&replica))
1343 .transpose()?;
1344 if let Some(held) = &held
1345 && crate::peek(held)?.1 > 0
1346 {
1347 return Err(io::Error::new(
1348 io::ErrorKind::WouldBlock,
1349 "Edits to the section wait to be published",
1350 )
1351 .into());
1352 }
1353 let source = self.storage.read(&section.path)?;
1354 let new = password.map(protected::Key::new).transpose()?;
1355 let image = onestore::protected::rekey(&source, key, new.as_ref())?;
1356 let (folder, filename) = split(&section.path);
1357 // A dot file, which discovery and OneNote pass over, until it replaces the section.
1358 let written = catalog_path(folder, &format!(".{filename}.snowbound"));
1359 if self.storage.exists(&written) {
1360 self.storage.delete(&written)?;
1361 }
1362 self.storage.create(&written, &image)?;
1363 if let Err(error) = self
1364 .storage
1365 .supersede(&section.path, &Stamp::of(&source)?, &written)
1366 {
1367 let _ = self.storage.delete(&written);
1368 return Err(error);
1369 }
1370 drop(held);
1371 remove_replica(&replica)?;
1372 let parent = self.folder(folder)?;
1373 if let Some(identity) = Entry::of(parent, section).identity {
1374 self.edit_toc(
1375 folder,
1376 &[onestore::TocEdit::Reidentify {
1377 identity,
1378 with: Store::parse(&image)?.header.file_id,
1379 }],
1380 )?;
1381 }
1382 self.refresh()?;
1383 Ok(new)
12211384 }
12221385
12231386 /// Where the replica of the section at catalog `path` lives, in the notebook's
......@@ -1250,22 +1413,16 @@ impl Notebook {
12501413 /// discovery read so that each is read once.
12511414 pub fn replicas(&mut self) -> Vec<Known> {
12521415 let mut images = self.read.take();
1253 let mut sections = Vec::new();
1254 let mut folders = vec![&self.catalog];
1255 while let Some(folder) = folders.pop() {
1256 for section in &folder.sections {
1257 if matches!(section.state, discover::SectionState::Readable { .. }) {
1258 sections.push(Known {
1259 path: section.path.clone(),
1260 replica: self.replica_path(&section.path).ok(),
1261 found: self.read.found(&section.path),
1262 image: images.remove(&section.path),
1263 });
1264 }
1265 }
1266 folders.extend(folder.groups.iter().rev());
1267 }
1268 sections
1416 self.catalog
1417 .sections()
1418 .filter(|section| matches!(section.state, discover::SectionState::Readable { .. }))
1419 .map(|section| Known {
1420 path: section.path.clone(),
1421 replica: self.replica_path(&section.path).ok(),
1422 found: self.read.found(&section.path),
1423 image: images.remove(&section.path),
1424 })
1425 .collect()
12691426 }
12701427
12711428 /// Keeps the sections of a mounted notebook in sync while they are not open
......@@ -1326,6 +1483,37 @@ impl Notebook {
13261483 path: &str,
13271484 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
13281485 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,
13291517 ) -> Result<Section> {
13301518 let section = self.section_path(path)?;
13311519 let (path, replicas) = (section.path.clone(), self.replica_folder(section));
......@@ -1355,7 +1543,7 @@ impl Notebook {
13551543 std::fs::create_dir_all(&replicas)?;
13561544 Ok(replica_file(&replicas, identity))
13571545 };
1358 Section::open_in(file, replica, connect, notify)
1546 Section::open_in(file, key, replica, connect, notify)
13591547 }
13601548}
13611549
......@@ -1411,7 +1599,18 @@ pub struct StoredPage {
14111599pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> {
14121600 let store = Store::parse(image)?;
14131601 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>> {
14151614 Ok(document
14161615 .pages()?
14171616 .into_iter()
......@@ -1461,7 +1660,7 @@ fn component(name: &str) -> bool {
14611660}
14621661
14631662/// 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> {
14651664 let store = Store::parse(image)?;
14661665 let index = RevisionIndex::parse(&store)?;
14671666 let document = Document::parse(&index)?;
......@@ -1508,6 +1707,19 @@ fn replica_location(notebook: &str, section: &discover::Section) -> String {
15081707 }
15091708}
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
15111723/// The replica in `cache` of the section `identity` names.
15121724pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
15131725 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();
......@@ -1635,13 +1847,14 @@ impl Section {
16351847 })?;
16361848 Ok(replica_file(&folder, identity))
16371849 };
1638 Self::open_in(file, replicas, connect, notify)
1850 Self::open_in(file, None, replicas, connect, notify)
16391851 }
16401852
1641 /// Opens the canonical section `file` through the replica `replica` names for its document
1642 /// identity and image.
1853 /// Opens the canonical section `file`, a protected one under `key`, through the replica
1854 /// `replica` names for its document identity and image.
16431855 fn open_in<R: Remote + 'static>(
16441856 file: PathBuf,
1857 key: Option<&protected::Key>,
16451858 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,
16461859 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
16471860 notify: impl Fn() + Send + 'static,
......@@ -1650,7 +1863,7 @@ impl Section {
16501863 let store = Store::parse(&source)?;
16511864 let identity = RevisionIndex::parse(&store)?.root;
16521865 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))?;
16541867 let remote = file.clone();
16551868 Self::start(file, replica, move || connect(&remote), notify)
16561869 }
......@@ -2015,3 +2228,6 @@ impl Drop for Section {
20152228 drop(self.worker.take());
20162229 }
20172230}
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 {
165165 fn commit(&self, _: &str, _: &Transaction) -> Result<()> {
166166 unimplemented!()
167167 }
168
169 fn supersede(&self, _: &str, _: &onestore::Stamp, _: &str) -> Result<()> {
170 unimplemented!()
171 }
168172}
169173
170174#[test]
crates/notebook/src/smb/mod.rs+29-2
......@@ -331,7 +331,7 @@ impl Client {
331331
332332 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010
333333 /// skips a hidden folder.
334 pub fn hide(&self, path: &str) -> io::Result<()> {
334 pub(crate) fn hide(&self, path: &str) -> io::Result<()> {
335335 // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES.
336336 let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?;
337337 let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?;
......@@ -389,7 +389,7 @@ impl Client {
389389
390390 /// Reads a bounded external payload while denying concurrent writes and deletion.
391391 /// 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>> {
393393 let mut file = self.open_shared(path, false, 1)?;
394394 let mut bytes = Vec::new();
395395 let mut block = [0; 65536];
......@@ -493,6 +493,33 @@ impl Client {
493493 self.commit(path, &checked(base), |file| onestore::confirm(file, base))
494494 }
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
496523 /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks.
497524 fn commit(
498525 &self,
crates/notebook/src/smb/tests.rs+47-4
......@@ -463,9 +463,16 @@ fn live_watch() {
463463#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
464464fn live_storage_inspection() {
465465 let reader = client();
466 for (index, fixture) in [
467 "native-encrypted/encrypted-01/notebook/synthetic.one",
468 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
466 // Stably unreadable, never contention: a protected section, and a file too damaged to say.
467 for (index, (fixture, unreadable)) in [
468 (
469 "native-encrypted/encrypted-01/notebook/synthetic.one",
470 io::ErrorKind::Unsupported,
471 ),
472 (
473 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
474 io::ErrorKind::InvalidData,
475 ),
469476 ]
470477 .into_iter()
471478 .enumerate()
......@@ -482,7 +489,7 @@ fn live_storage_inspection() {
482489 assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source);
483490 assert_eq!(
484491 reader.read(&path, source.len()).unwrap_err().kind(),
485 io::ErrorKind::WouldBlock
492 unreadable
486493 );
487494 let maintenance = client();
488495 let guard = maintenance.open(&path, false).unwrap();
......@@ -777,6 +784,42 @@ fn live_structure() {
777784 assert_eq!(header.ancestor, toc_id);
778785 assert_eq!(header.name_crc, 0x6108912a);
779786 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:?}");
780823 for path in [
781824 "Renamed.one",
782825 "Kept/Inner.one",
crates/notebook/src/sync.rs+32-28
......@@ -84,8 +84,7 @@ struct Blocked {
8484}
8585
8686fn state(connection: &Connection) -> Result<State> {
87 let base = base::stamp(connection, base::Image::Base)?
88 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
87 let base = base::base_stamp(connection)?;
8988 let blocked = connection
9089 .query_row(
9190 "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> {
158157impl Replica {
159158 /// Returns a durable receipt or the persisted state of a locally acknowledged edit.
160159 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
161 status(&*self.lock()?, id)
160 status(&*self.lock()?, id, self.section.key.as_ref())
162161 }
163162
164163 /// The last observed remote image. Observation alone does not acknowledge any pending
165164 /// edit's remote durability.
166165 pub(crate) fn remote_snapshot(&self) -> Result<Vec<u8>> {
167166 let connection = self.lock()?;
168 let image = match base::read(&connection, base::Image::Remote)? {
169 Some(image) => Some(image),
170 None => base::read(&connection, base::Image::Base)?,
171 };
172 Ok(image.ok_or_else(|| {
173 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
174 })?)
167 match base::read(&connection, base::Image::Remote)? {
168 Some(image) => Ok(image),
169 None => base::base(&connection),
170 }
175171 }
176172
177173 /// Publishes the oldest unpublished batch, rebasing the queue first when the remote
......@@ -208,7 +204,8 @@ impl Replica {
208204 return Ok(Synced {
209205 edit: Some((
210206 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),
212209 )),
213210 changed: Vec::new(),
214211 });
......@@ -217,9 +214,15 @@ impl Replica {
217214 observed if observed == state.base => None,
218215 _ => {
219216 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 }
220224 // A read image is compared whole: a stamp stands for it only when read alone.
221 let base = base::read(&*self.lock()?, base::Image::Base)?;
222 (base.as_deref() != Some(&image[..])).then_some(image)
225 (base::base(&*self.lock()?)? != image).then_some(image)
223226 }
224227 };
225228 let mut changed = Vec::new();
......@@ -265,7 +268,8 @@ impl Replica {
265268 return Ok(Synced {
266269 edit: Some((
267270 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),
269273 )),
270274 changed,
271275 });
......@@ -301,7 +305,8 @@ impl Replica {
301305 return Ok(Synced {
302306 edit: Some((
303307 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),
305310 )),
306311 changed,
307312 });
......@@ -329,9 +334,7 @@ impl Replica {
329334 let id = newest(&*self.lock()?, batch)?;
330335 let Some(transaction) = transaction else {
331336 // Edits that changed nothing are published once the remote's image is durable.
332 let base = base::stamp(&*self.lock()?, base::Image::Base)?.ok_or_else(|| {
333 io::Error::new(io::ErrorKind::InvalidData, "The cache holds no base image")
334 })?;
337 let base = base::base_stamp(&*self.lock()?)?;
335338 if let Err(error) = remote.confirm(&base) {
336339 if error.state == CommitState::Committed {
337340 self.acknowledge(batch, None, None)?;
......@@ -373,15 +376,13 @@ impl Replica {
373376 sealed: Option<&Transaction>,
374377 revisions: &BTreeMap<ExGuid, ExGuid>,
375378 ) -> Result<bool> {
376 let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| {
377 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
378 })?;
379 let base = base::base(&*self.lock()?)?;
379380 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())?;
381382 if let Some(sealed) = sealed {
382383 local.replay(sealed)?;
383384 }
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())?;
385386 // A page's versions change without its page changing.
386387 let versions = |section: &mut onestore::Section<'_>| {
387388 section.versions().map(|versions| {
......@@ -403,7 +404,7 @@ impl Replica {
403404 }
404405
405406 fn receipt(&self, id: u64) -> Result<ExGuid> {
406 match status(&*self.lock()?, id)? {
407 match status(&*self.lock()?, id, self.section.key.as_ref())? {
407408 Some(EditStatus::Published { revision }) => Ok(revision),
408409 _ => Err(io::Error::other("A published edit has no receipt").into()),
409410 }
......@@ -431,7 +432,7 @@ impl Replica {
431432 |row| row.get::<_, String>(0),
432433 )?)?,
433434 };
434 for queued in queue::load(&database, Some(batch))? {
435 for queued in queue::load(&database, self.section.key.as_ref(), Some(batch))? {
435436 database.execute(
436437 "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)",
437438 params![
......@@ -527,7 +528,11 @@ impl Replica {
527528 }
528529}
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>> {
531536 let id = signed(id)?;
532537 if let Some(revision) = connection
533538 .query_row(
......@@ -563,8 +568,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
563568 None => None,
564569 Some((true, revisions, edit)) => {
565570 let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?;
566 let edit: onestore::op::Edit = serde_json::from_str(&edit)
567 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
571 let edit = queue::parse(key, &edit)?;
568572 let revision = revision_of(&edit, &revisions)?;
569573 Some(EditStatus::AwaitingConfirmation { revision })
570574 }
crates/notebook/src/worker.rs+1
......@@ -166,6 +166,7 @@ impl Replica {
166166 ));
167167 }
168168 let mut owner = self
169 .section
169170 .worker
170171 .lock()
171172 .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?;
crates/notebook/src/working.rs+63-56
......@@ -8,6 +8,7 @@ use onestore::{
88 Arena, ExGuid, Section, Transaction,
99 op::{Edit, OpError},
1010 page::Page,
11 protected::Key,
1112};
1213use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
1314use std::{
......@@ -96,18 +97,19 @@ impl Request {
9697 }
9798}
9899
99type Worker = Arc<Mutex<Weak<Signal>>>;
100
101100/// The section thread as the replica sees it. Rereading the section after the queue was
102101/// replaced (a rebase, a released attempt) happens on a new thread while the current one
103102/// keeps answering page reads from the section as it was; the new thread then takes the
104103/// requests over, so reads never wait for a rebuild.
105104pub(crate) struct Thread {
106 connection: Arc<Mutex<Connection>>,
107 worker: Worker,
105 pub(crate) connection: Mutex<Connection>,
106 /// The sync worker, which a durable burst of edits wakes.
107 pub(crate) worker: Mutex<Weak<Signal>>,
108108 /// Taken when the replica drops, which ends every section thread.
109109 sender: Mutex<Option<mpsc::Sender<Request>>>,
110110 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>,
111113}
112114
113115impl Thread {
......@@ -148,16 +150,14 @@ impl Thread {
148150}
149151
150152/// Starts the section thread once the queue opens.
151pub(crate) fn spawn(
152 connection: Arc<Mutex<Connection>>,
153 worker: Worker,
154) -> Result<(Arc<Thread>, ExGuid)> {
153pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thread>, ExGuid)> {
155154 let (sender, requests) = mpsc::channel();
156155 let shared = Arc::new(Thread {
157 connection,
158 worker,
156 connection: Mutex::new(connection),
157 worker: Mutex::new(Weak::new()),
159158 sender: Mutex::new(Some(sender)),
160159 threads: Mutex::new(Vec::new()),
160 key,
161161 });
162162 let (ready, opened) = mpsc::sync_channel(1);
163163 shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?;
......@@ -188,7 +188,7 @@ fn run(
188188) {
189189 loop {
190190 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()) {
192192 Ok(working) => working,
193193 Err(error) => {
194194 if let Some(ready) = ready.take() {
......@@ -237,17 +237,19 @@ enum Job {
237237fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) {
238238 let answer: Box<dyn FnOnce() + Send> = match job {
239239 Job::Reopen { reply } => Box::new(move || reply(Ok(()))),
240 Job::Rebase { image, reply } => match rebase(&shared.connection, image) {
241 Ok(changed) => Box::new(move || reply(Ok(changed))),
242 Err(error) => {
243 reply(Err(error));
244 let _ = signal.send(Request::Resume);
245 return;
240 Job::Rebase { image, reply } => {
241 match rebase(&shared.connection, image, shared.key.as_ref()) {
242 Ok(changed) => Box::new(move || reply(Ok(changed))),
243 Err(error) => {
244 reply(Err(error));
245 let _ = signal.send(Request::Resume);
246 return;
247 }
246248 }
247 },
249 }
248250 };
249251 let arena = Arena::default();
250 let opened = Working::open(&arena, &shared.connection);
252 let opened = Working::open(&arena, &shared.connection, shared.key.as_ref());
251253 let (to, from) = mpsc::sync_channel(1);
252254 if signal.send(Request::Handover(to)).is_err() {
253255 return;
......@@ -286,8 +288,8 @@ struct Working<'a> {
286288}
287289
288290impl<'a> Working<'a> {
289 fn open(arena: &'a Arena, connection: &Mutex<Connection>) -> Result<Self> {
290 let (section, open, touched) = replay(arena, &*lock(connection)?)?;
291 fn open(arena: &'a Arena, connection: &Mutex<Connection>, key: Option<&Key>) -> Result<Self> {
292 let (section, open, touched) = replay(arena, &*lock(connection)?, key)?;
291293 Ok(Self {
292294 section,
293295 open,
......@@ -301,7 +303,7 @@ impl<'a> Working<'a> {
301303 requests: &mpsc::Receiver<Request>,
302304 backlog: &mut VecDeque<Request>,
303305 ) -> Next {
304 let (connection, worker) = (&*shared.connection, &shared.worker);
306 let connection = &shared.connection;
305307 // While another thread rebuilds the section, reads answer from this one and every
306308 // other request waits for the rebuilt section.
307309 let mut held: Option<VecDeque<Request>> = None;
......@@ -356,7 +358,7 @@ impl<'a> Working<'a> {
356358 edit,
357359 reply,
358360 } => self
359 .accept(author, edit, reply, &mut accepted, connection, worker)
361 .accept(author, edit, reply, &mut accepted, shared)
360362 .err()
361363 .unwrap_or(false),
362364 Request::Page { space, reply } => {
......@@ -390,7 +392,7 @@ impl<'a> Working<'a> {
390392 false
391393 }
392394 Request::Flush { reply } => {
393 let written = self.flush(connection, worker, &mut accepted);
395 let written = self.flush(shared, &mut accepted);
394396 reply(if written {
395397 Ok(())
396398 } else {
......@@ -399,7 +401,7 @@ impl<'a> Working<'a> {
399401 !written
400402 }
401403 Request::Seal { reply } => {
402 if !self.flush(connection, worker, &mut accepted) {
404 if !self.flush(shared, &mut accepted) {
403405 reply(Err(
404406 io::Error::other("The queue could not be written").into()
405407 ));
......@@ -412,7 +414,7 @@ impl<'a> Working<'a> {
412414 }
413415 }
414416 Request::Rebase { image, reply } => {
415 if self.flush(connection, worker, &mut accepted) {
417 if self.flush(shared, &mut accepted) {
416418 held = self
417419 .rebuild(shared, Job::Rebase { image, reply })
418420 .then(VecDeque::new);
......@@ -425,7 +427,7 @@ impl<'a> Working<'a> {
425427 }
426428 }
427429 Request::Reopen { reply } => {
428 self.flush(connection, worker, &mut accepted);
430 self.flush(shared, &mut accepted);
429431 held = self
430432 .rebuild(shared, Job::Reopen { reply })
431433 .then(VecDeque::new);
......@@ -438,7 +440,7 @@ impl<'a> Working<'a> {
438440 return Next::Reopen;
439441 }
440442 }
441 if !self.flush(connection, worker, &mut accepted) {
443 if !self.flush(shared, &mut accepted) {
442444 return Next::Reopen;
443445 }
444446 }
......@@ -476,8 +478,7 @@ impl<'a> Working<'a> {
476478 edit: Edit,
477479 reply: Reply<u64>,
478480 accepted: &mut Vec<Accepted>,
479 connection: &Mutex<Connection>,
480 worker: &Worker,
481 shared: &Thread,
481482 ) -> std::result::Result<(), bool> {
482483 match self.section.apply(&author, &edit) {
483484 Ok(()) => {
......@@ -493,7 +494,7 @@ impl<'a> Working<'a> {
493494 Err(error) => {
494495 let broken = matches!(error, OpError::Failed(_));
495496 if broken {
496 self.flush(connection, worker, accepted);
497 self.flush(shared, accepted);
497498 }
498499 reply(Err(error.into()));
499500 Err(broken)
......@@ -503,17 +504,12 @@ impl<'a> Working<'a> {
503504
504505 /// Writes the accepted edits in one transaction, then answers their senders; false
505506 /// when the write failed and the section holds edits the queue lacks.
506 fn flush(
507 &mut self,
508 connection: &Mutex<Connection>,
509 worker: &Worker,
510 accepted: &mut Vec<Accepted>,
511 ) -> bool {
507 fn flush(&mut self, shared: &Thread, accepted: &mut Vec<Accepted>) -> bool {
512508 if accepted.is_empty() {
513509 return true;
514510 }
515511 let written = (|| -> Result<Vec<u64>> {
516 let mut connection = lock(connection)?;
512 let mut connection = lock(&shared.connection)?;
517513 let transaction =
518514 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
519515 let batch = match self.open {
......@@ -527,6 +523,7 @@ impl<'a> Working<'a> {
527523 for edit in accepted.iter() {
528524 ids.push(queue::insert(
529525 &transaction,
526 shared.key.as_ref(),
530527 None,
531528 batch,
532529 &edit.author,
......@@ -543,7 +540,7 @@ impl<'a> Working<'a> {
543540 for (edit, id) in accepted.drain(..).zip(ids) {
544541 (edit.reply)(Ok(id));
545542 }
546 crate::edited(worker);
543 crate::edited(&shared.worker);
547544 }
548545 Err(error) => {
549546 let message = error.to_string();
......@@ -600,7 +597,11 @@ impl<'a> Working<'a> {
600597/// Replays every queued edit on `image`, which becomes the base; each page whose local
601598/// version the remote could not take gains a conflict page holding it, queued as a new edit.
602599/// 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>> {
604605 let (base_image, image, edits) = {
605606 let connection = lock(connection)?;
606607 if connection.query_row(
......@@ -614,22 +615,20 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<
614615 )
615616 .into());
616617 }
617 let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| {
618 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
619 })?;
618 let base_image = base::base(&connection)?;
620619 let image = match image {
621620 Some(image) => image,
622621 None => {
623622 base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone())
624623 }
625624 };
626 (base_image, image, queue::load(&connection, None)?)
625 (base_image, image, queue::load(&connection, key, None)?)
627626 };
628627 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)?;
630629 let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect();
631630 let remote_arena = Arena::default();
632 let remote = Section::open(&remote_arena, image.clone())?;
631 let remote = open(&remote_arena, image.clone(), key)?;
633632 if old.root() != remote.root() {
634633 return Err(io::Error::new(
635634 io::ErrorKind::InvalidInput,
......@@ -645,10 +644,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<
645644 let mut converged = BTreeSet::new();
646645 let (rewritten, added) = loop {
647646 let arena = Arena::default();
648 let mut new = Section::open(&arena, image.clone())?;
647 let mut new = open(&arena, image.clone(), key)?;
649648 let merged = merge::rebase(&mut old, &mut new, &edits, &converged)?;
650649 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);
652651 }
653652 let pages: BTreeMap<ExGuid, Page> = merged
654653 .conflicts
......@@ -694,10 +693,10 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<
694693 transaction.execute("UPDATE edits SET batch=?1", [batch])?;
695694 transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?;
696695 for (id, edit) in rewritten {
697 queue::rewrite(&transaction, id, &edit)?;
696 queue::rewrite(&transaction, key, id, &edit)?;
698697 }
699698 for (author, edit) in &added {
700 queue::insert(&transaction, None, batch, author, edit)?;
699 queue::insert(&transaction, key, None, batch, author, edit)?;
701700 }
702701 if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 {
703702 transaction.execute("DELETE FROM batches", [])?;
......@@ -743,10 +742,10 @@ fn batches(connection: &Connection) -> Result<Vec<(i64, Option<Option<Transactio
743742fn replay<'a>(
744743 arena: &'a Arena,
745744 connection: &Connection,
745 key: Option<&Key>,
746746) -> Result<(Section<'a>, Option<i64>, BTreeSet<ExGuid>)> {
747 let image = base::read(connection, base::Image::Base)?
748 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
749 let mut section = Section::open(arena, image)?;
747 let image = base::base(connection)?;
748 let mut section = open(arena, image, key)?;
750749 let mut open = None;
751750 let mut touched = BTreeSet::new();
752751 for (batch, sealed) in batches(connection)? {
......@@ -761,7 +760,7 @@ fn replay<'a>(
761760 Some(Some(transaction)) => section.replay(&transaction)?,
762761 Some(None) => {}
763762 None => {
764 for queued in queue::load(connection, Some(batch))? {
763 for queued in queue::load(connection, key, Some(batch))? {
765764 section
766765 .apply(&queued.author, &queued.edit)
767766 .map_err(|error| {
......@@ -781,13 +780,21 @@ fn replay<'a>(
781780
782781/// The image the queue leaves, its unsealed edits sealed as one more revision; that
783782/// 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>> {
785784 let arena = Arena::default();
786 let (mut section, ..) = replay(&arena, connection)?;
785 let (mut section, ..) = replay(&arena, connection, key)?;
787786 section.seal()?;
788787 Ok(section.image())
789788}
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
791798/// The sealed batch waiting for publication, if any.
792799pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> {
793800 let row: Option<(i64, String)> = connection
crates/notebook/tests/cache.rs+2-2
......@@ -66,14 +66,14 @@ fn a_cache_created_meanwhile_opens() {
6666 let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap();
6767 let directory = tempfile::tempdir().unwrap();
6868 let path = directory.path().join("raced.sqlite");
69 let replica = Replica::open_or_create(&path, || {
69 let replica = Replica::open_or_create(&path, None, || {
7070 drop(Replica::create(&path, &source)?);
7171 Ok(source.clone())
7272 })
7373 .unwrap();
7474 assert_eq!(replica.pages().unwrap()[0].1, "Raced");
7575 drop(replica);
76 let reopened = Replica::open_or_create(&path, || unreachable!()).unwrap();
76 let reopened = Replica::open_or_create(&path, None, || unreachable!()).unwrap();
7777 assert_eq!(reopened.pages().unwrap()[0].1, "Raced");
7878}
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#![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};
411use std::path::Path;
512
6/// A locked section is discovered as such and reads with its password, page by page.
7#[test]
8fn a_locked_section_unlocks_for_reading() {
9 let root = Path::new(concat!(
10 env!("CARGO_MANIFEST_DIR"),
11 "/../../corpus/native-protected-boundaries"
12 ));
13#[path = "support/server.rs"]
14mod server;
15use server::Server;
16
17fn fixture(name: &str) -> (std::path::PathBuf, String) {
18 let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(name);
1319 let manifest: serde_json::Value =
1420 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");
16100 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();
18104 let section = &notebook.catalog().sections[0];
19105 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()));
23106 assert!(matches!(
24107 notebook.unlock(&section.path, "wrong"),
25 Err(notebook::Error::Protected(
26 onestore::protected::Error::PasswordMismatch
27 ))
108 Err(notebook::Error::Protected(Error::PasswordMismatch))
109 ));
110 assert!(notebook.section(&section.path, || {}).is_err());
111 let key = notebook.unlock(&section.path, &password).unwrap();
112 let session = notebook
113 .section_unlocked(&section.path, &key, || {})
114 .unwrap();
115 let pages = session.pages().unwrap();
116 assert_eq!(pages.len(), 11);
117 assert!(pages.iter().all(|(_, title, _)| !title.is_empty()));
118 let title = &pages[0].1;
119 session.close().unwrap();
120 // The replica holds the section as its file does: encrypted.
121 assert!(!leaks(
122 &notebook.replica_path(&section.path).unwrap(),
123 title
28124 ));
29125}
30126
31/// An unlocked page is edited as ops stored under the section's key, then reads back with
32/// the same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for
33/// a cold reopen in OneNote.
127/// Queued edits and their payloads are sealed in the replica, which opens again only under
128/// the section's key, and publish into the file under it.
34129#[test]
35fn an_unlocked_page_is_saved_under_the_section_key() {
36 use onestore::page::{PageObject, Paragraph, text::new_id};
37 let root = Path::new(concat!(
38 env!("CARGO_MANIFEST_DIR"),
39 "/../../corpus/native-encrypted"
130fn queued_edits_are_sealed_and_publish_under_the_key() {
131 const SECRET: &str = "Queued in confidence";
132 let (source, key) = protected("Opening line");
133 let (space, text, _) = first_text(&source, &key);
134 let directory = tempfile::tempdir().unwrap();
135 let path = directory.path().join("cache.sqlite");
136 let cache = Replica::open_or_create(&path, Some(&key), || Ok(source.clone())).unwrap();
137 cache
138 .apply("Fixture", replace(space, text, 0..0, SECRET))
139 .unwrap();
140 let mut page = cache.page(space).unwrap();
141 let PageObject::Outline(outline) = page
142 .objects
143 .iter_mut()
144 .find(|object| matches!(object, PageObject::Outline(_)))
145 .unwrap()
146 else {
147 unreachable!()
148 };
149 let mut file = outline.paragraphs[0].clone();
150 file.id = onestore::page::text::new_id().unwrap();
151 file.content = onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
152 id: onestore::page::text::new_id().unwrap(),
153 filename: "sealed.txt".into(),
154 source_path: None,
155 size: Some([24.0, 24.0]),
156 layout: Default::default(),
157 bytes: Some(std::sync::Arc::from(SECRET.as_bytes())),
158 preview: None,
159 recording: None,
160 tags: Vec::new(),
161 });
162 outline.paragraphs.push(file);
163 let before = cache.page(space).unwrap();
164 let ops = onestore::op::lower_page(&before, &page).unwrap();
165 cache
166 .apply(
167 "Fixture",
168 Edit {
169 at: 134_000_000_000_000_001,
170 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
171 },
172 )
173 .unwrap();
174 drop(cache);
175 assert!(!leaks(&path, SECRET));
176 assert!(Replica::open(&path).is_err());
177 assert!(
178 Replica::open_or_create(
179 &path,
180 Some(&Key::new("another").unwrap()),
181 || unreachable!()
182 )
183 .is_err()
184 );
185 let cache = Replica::open_or_create(&path, Some(&key), || unreachable!()).unwrap();
186 assert_eq!(cache.pending().unwrap().len(), 2);
187 let mut server = Server::new(&source);
188 assert!(matches!(
189 cache.sync_once(&mut server).unwrap().edit,
190 Some((_, EditStatus::Published { .. }))
40191 ));
41 let manifest: serde_json::Value =
42 serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap();
43 let password = manifest["password"].as_str().unwrap();
192 drop(cache);
193 assert!(!leaks(&path, SECRET));
194 assert!(
195 !server
196 .durable
197 .windows(SECRET.len())
198 .any(|w| w == SECRET.as_bytes())
199 );
200 let arena = Arena::default();
201 let stored = Section::unlock(&arena, server.durable.clone(), &key).unwrap();
202 let published = stored.page(space).unwrap();
203 assert!(texts(&published)[0].1.starts_with(SECRET));
204 assert!(format!("{published:?}").contains("sealed.txt"));
205 let archived = directory.path().join("recovery.sqlite");
206 Replica::open_or_create(&path, Some(&key), || unreachable!())
207 .unwrap()
208 .export_recovery(&archived)
209 .unwrap();
210 assert!(!leaks(&archived, SECRET));
211 assert!(notebook::Recovery::open(&archived).is_err());
212 assert!(notebook::Recovery::open_unlocked(&archived, &key).is_ok());
213}
214
215/// Edits of the same text on two copies of a protected section merge as an ordinary
216/// section's do: the remote's stays the page, the local one becomes a conflict page, and
217/// every revision stays under the key.
218#[test]
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";
44270 let temporary = tempfile::tempdir().unwrap();
45 let copy = temporary.path().join("notebook");
46 std::fs::create_dir(&copy).unwrap();
47 for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() {
48 let entry = entry.unwrap();
49 std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap();
50 }
51 let notebook = Notebook::open(&copy, temporary.path().join("cache")).unwrap();
52 let section = notebook
53 .catalog()
54 .sections
55 .iter()
56 .find(|section| matches!(section.state, SectionState::Locked))
271 let folder = temporary.path().join("notebook");
272 let cache = temporary.path().join("cache");
273 let creation = onestore::PageCreation::new(None, Some(TEXT), "Fixture").unwrap();
274 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
275 let path = notebook.catalog().sections[0].path.clone();
276 let plain = notebook.replica_path(&path).unwrap();
277 notebook.section(&path, || {}).unwrap().close().unwrap();
278 assert!(plain.exists());
279 let identity = notebook.catalog().sections[0].file_id;
280
281 let first = notebook
282 .set_password(&path, None, Some("first"))
57283 .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 })
70284 .unwrap();
71 let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap();
72 let mut added = paragraphs[at].clone();
73 added.id = new_id().unwrap();
74 added.style = None;
75 added.lists.clear();
76 added.tags.clear();
77 let text = added.text_mut().unwrap();
78 text.id = new_id().unwrap();
79 let format = text.text.format_at(0).unwrap().clone();
80 text.text = Paragraph::new(
81 "Written while protected 🦀".to_owned(),
82 onestore::document::Format {
83 font: Some("Calibri".into()),
84 font_size: Some(11.0),
85 language: Some(1033),
86 ..Default::default()
87 },
285 assert!(!plain.exists());
286 let section = &notebook.catalog().sections[0];
287 assert!(matches!(section.state, SectionState::Locked));
288 assert_ne!(section.file_id, identity);
289 assert!(
290 notebook
291 .catalog()
292 .toc
293 .as_ref()
294 .unwrap()
295 .unresolved
296 .is_empty()
88297 );
89 paragraphs.insert(at + 1, added);
90 paragraphs[at]
91 .text_mut()
298 let image = notebook.read_section(&path).unwrap();
299 let utf16: Vec<u8> = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect();
300 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
301 let key = notebook.unlock(&path, "first").unwrap();
302 assert_eq!(key.secret(), first.secret());
303 let session = notebook.section_unlocked(&path, &key, || {}).unwrap();
304 assert_eq!(session.pages().unwrap()[0].1, TEXT);
305 session.close().unwrap();
306 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));
307
308 assert!(notebook.set_password(&path, None, Some("second")).is_err());
309 let second = notebook
310 .set_password(&path, Some(&key), Some("second"))
92311 .unwrap()
93 .text
94 .append(Paragraph::new(
95 " and edited under its key".to_owned(),
96 format,
97 ))
98312 .unwrap();
99 let edit = onestore::op::Edit {
100 at: 134_000_000_000_000_000,
101 ops: onestore::op::lower_page(&before, &page)
313 assert!(notebook.unlock(&path, "first").is_err());
314 let session = notebook.section_unlocked(&path, &second, || {}).unwrap();
315 assert_eq!(session.pages().unwrap()[0].1, TEXT);
316 session.close().unwrap();
317
318 assert!(
319 notebook
320 .set_password(&path, Some(&second), None)
102321 .unwrap()
103 .into_iter()
104 .map(|op| onestore::op::Op::Page { space, op })
105 .collect(),
106 };
322 .is_none()
323 );
324 let section = &notebook.catalog().sections[0];
325 assert!(matches!(section.state, SectionState::Readable { .. }));
326 let session = notebook.section(&path, || {}).unwrap();
327 assert_eq!(session.pages().unwrap()[0].1, TEXT);
328}
329
330/// A section whose edits wait to be published keeps its password until they are.
331#[test]
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 );
107397 assert!(matches!(
108 notebook.apply_unlocked(&section, "wrong", &mut unlocked, "Rust", &edit),
109 Err(notebook::Error::Protected(
110 onestore::protected::Error::PasswordMismatch
111 ))
398 again.unwrap().catalog().sections[0].state,
399 SectionState::Locked
112400 ));
113 let mut stale = notebook.unlock(&section, password).unwrap();
114 notebook
115 .apply_unlocked(&section, password, &mut unlocked, "Rust", &edit)
401 assert!(!leaks(&replica, TITLE));
402}
403
404/// An open section that another device protects stops synchronizing as protected, and its
405/// plaintext goes once it closes.
406#[test]
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))
116449 .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 }
118635 assert!(
119636 notebook
120 .apply_unlocked(&section, password, &mut stale, "Rust", &edit)
121 .is_err()
637 .catalog()
638 .toc
639 .as_ref()
640 .unwrap()
641 .unresolved
642 .is_empty()
122643 );
123 let (_, stored) = notebook.unlock(&section, password).unwrap().pages.remove(0);
124 assert!(stored.objects == page.objects);
644 let key = notebook
645 .unlock(&path("Conflicted"), "Conflicted gate password")
646 .unwrap();
647 let stored = Section::unlock(
648 &Arena::default(),
649 notebook.read_section(&path("Conflicted")).unwrap(),
650 &key,
651 )
652 .unwrap()
653 .conflicts()
654 .unwrap();
655 // Each page's first paragraph clashed, so each keeps a conflict page.
656 assert_eq!(stored.len(), 2);
125657 if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") {
126658 let export = Path::new(&export);
127 std::fs::create_dir_all(export).unwrap();
128 for entry in std::fs::read_dir(&copy).unwrap() {
129 let entry = entry.unwrap();
130 std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap();
131 }
659 copy(&folder, &export.join("notebook"));
660 std::fs::write(
661 export.join("passwords.json"),
662 serde_json::to_vec_pretty(&passwords).unwrap(),
663 )
664 .unwrap();
132665 }
133666}
crates/notebook/tests/support/page_schedule.rs+6-44
......@@ -5,12 +5,9 @@ use crate::{
55use notebook::{Remote, Replica};
66use onestore::{
77 CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction,
8 document::{Document, Format, Kind, Layout},
8 document::{Document, Kind},
99 op::SectionOp,
10 page::{
11 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
12 text::new_id,
13 },
10 page::{Page, PageObject},
1411};
1512use std::{io, sync::LazyLock};
1613
......@@ -54,50 +51,15 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> {
5451/// Adds a body outline holding one plain paragraph, returning its text identity. A page the
5552/// section just created has no body text for `model_ops::insert_outline` to copy formatting from.
5653pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {
57 // The writer creates outline text with the store's default style; the model must state it.
58 let format = Format {
59 font: Some("Calibri".to_owned()),
60 font_size: Some(11.0),
61 language: Some(0x409),
62 ..Default::default()
63 };
64 let content = TextObject {
65 id: new_id().unwrap(),
66 date_field: None,
67 text: Paragraph::new(text.into(), format),
68 tags: Vec::new(),
69 };
70 let id = content.id;
71 let outline = Outline {
72 id: new_id().unwrap(),
73 title: false,
74 min_width: None,
75 layout: Layout {
76 x: Some(36.0),
77 y: Some(36.0),
78 ..Default::default()
79 },
80 indents: Vec::new(),
81 paragraphs: vec![PageParagraph {
82 id: new_id().unwrap(),
83 parent: None,
84 level: 1,
85 style: None,
86 format: Default::default(),
87 content: ParagraphContent::Text(content),
88 lists: Vec::new(),
89 tags: Vec::new(),
90 media: Default::default(),
91 collapsed: false,
92 }],
93 unsupported: Vec::new(),
94 };
54 let paragraph = ops::paragraph(text);
55 let id = paragraph.text().unwrap().id;
9556 let at = page
9657 .objects
9758 .iter()
9859 .position(|object| matches!(object, PageObject::Title(_)))
9960 .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]));
10163 id
10264}
10365
crates/onestore/Cargo.toml+6-9
......@@ -4,9 +4,6 @@ version = "0.1.0"
44edition = "2024"
55publish = false
66
7[features]
8protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"]
9
107[dependencies]
118base64 = "0.22.1"
129bumpalo = "3.20.3"
......@@ -14,12 +11,12 @@ serde_json = "1.0.151"
1411md5 = "0.8.1"
1512getrandom = "0.4.3"
1613serde = { version = "1.0.229", features = ["derive"] }
17aes = { version = "0.9.3", features = ["zeroize"], optional = true }
18cbc = { version = "0.2.1", features = ["zeroize"], optional = true }
19roxmltree = { version = "0.21.1", optional = true }
20sha1 = { version = "0.11.0", features = ["zeroize"], optional = true }
21subtle = { version = "2.6.1", optional = true }
22zeroize = { version = "1.9.0", optional = true }
14aes = { version = "0.9.3", features = ["zeroize"] }
15cbc = { version = "0.2.1", features = ["zeroize"] }
16roxmltree = "0.21.1"
17sha1 = { version = "0.11.0", features = ["zeroize"] }
18subtle = "2.6.1"
19zeroize = "1.9.0"
2320
2421[target.'cfg(target_os = "macos")'.dependencies]
2522nix = { 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`.
4747| `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 |
4848| `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 |
4949| `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 |
5153| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
5254| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |
5355| `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`.
5557| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
5658| `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 |
5759| `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`) |
5861| `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
5962| `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 |
6063| `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`.
6871| `op::predict` | The page an op leaves, as the section stores it and reading it back shows it |
6972| `read_file` | Read a snapshot under whole-file exclusion |
7073| `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
7377Edits enter a kept-open `Section` as ops and leave as one appended revision per
7478changed space; only section and page creation, imports and opening files handle
......@@ -78,14 +82,12 @@ navigation titles update in the same transaction; unsupported fields,
7882protected objects and split surrogate pairs are
7983rejected before writing. Appended snapshots cap revision dependency depth at 512
8084while retaining historical revisions. TOC snapshots can remap encoded CompactIDs
81without changing their resolved references. Password-protected
82sections retain their encrypted structure and payloads. With the optional
83`protected` feature, `protected::UnlockedSection` opens native OneNote 2010
84AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect
85passwords, unsupported protection profiles and work-limit failures remain distinct.
86The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them
87under the section's key (fresh IV per object, payloads under the file IV); `Section`
88rejects protected sections.
85without changing their resolved references. A password-protected section opens with
86`Section::unlock` under the `Key` its password opens, and every revision it seals names
87the key (MS-ONESTORE 2.5.19 asks that of each revision; OneNote names it only in those
88without a dependency, and reads both). `Section::open` refuses one. Incorrect
89passwords, unsupported protection profiles and work-limit failures remain distinct
90(`protected::Error`).
8991`PageCreation::new` appends, or inserts before the first page space of an existing
9092series. `Some("")` creates an empty title field; `None` omits the title node.
9193`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
331333and occurrence to withhold. The captured trace and result files are the regression
332334oracle; replaying the native experiments requires the supplied Windows/share setup.
333335
334## Protected inspection
336## 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
336351```rust,no_run
337# #[cfg(feature = "protected")]
338# fn example() {
339use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}};
340# fn inspect(bytes: &[u8], password: &str) -> Result<(), Box<dyn std::error::Error>> {
341let store = Store::parse(bytes)?;
342let index = RevisionIndex::parse(&store)?;
343let unlocked = UnlockedSection::open(&index, password, Limits::default())?;
344let document = unlocked.document()?;
345assert!(!document.pages()?.is_empty());
346drop(document);
347drop(unlocked);
352use onestore::{Arena, Section, protected::{Key, rekey}};
353# fn example(image: Vec<u8>, password: &str) -> Result<(), Box<dyn std::error::Error>> {
354let key = Key::open(&image, password)?;
355let arena = Arena::default();
356let mut section = Section::unlock(&arena, image.clone(), &key)?;
357assert!(!section.pages()?.is_empty());
358let changed = rekey(&image, Some(&key), Some(&Key::new("another password")?))?;
359# drop(changed);
348360# Ok(()) }
349# }
350361```
351362
352This example requires `features = ["protected"]`. The owner retains no password;
353its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed
354strings, serialized models and exports have their own lifetimes. These copies are
355plaintext, and dropping the unlock owner does not clear them. CBC has no general
363A `Key` holds no password; its key is cleared when its last clone drops. A `Section`
364decodes into its `Arena`, which is not cleared; drop both when the section locks.
365`UnlockedSection` owns its decoded buffers and clears them on drop; the views it lends
366cannot outlive it, while copies of parsed strings, serialized models and exports are
367plaintext with lifetimes of their own. CBC has no general
356368ciphertext-authentication guarantee; native read-only hashes and model validation
357369check the corresponding structure. Internal payloads are decoded; external payload
358370references remain references, and their protected decoding is not implemented.
359371
360For a deliberate plaintext diagnostic export, build the notebook exporter with
361`--features protected` and pass `--password-file PATH` after the source and optional
372For a deliberate plaintext diagnostic export, run the notebook exporter
373(`examples/document`) with `--password-file PATH` after the source and optional
362374new output directory. The file contains exact UTF-8 password bytes; no newline is
363375removed or Unicode normalization applied. The exporter creates protected exports
364376under 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::{
44 write::{Commit, LiveRevision, PropertyObject, declared, differing},
55};
66#[cfg(test)]
7use crate::{
8 RevisionIndex, Store,
9 document::{Document, Space},
10 write::chain_depth,
11};
7use crate::{RevisionIndex, Store, document::Document, write::chain_depth};
128use bumpalo::Bump;
139use std::{
1410 collections::{BTreeMap, BTreeSet},
......@@ -50,7 +46,10 @@ impl<'a> ActivePage<'a> {
5046 let view = document
5147 .spaces
5248 .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 })
5453 .ok_or(Error {
5554 offset: 0,
5655 message: "The active page is unavailable",
crates/onestore/src/commit.rs+53-4
......@@ -65,9 +65,17 @@ impl FileIo {
6565 }
6666 })?);
6767 #[cfg(unix)]
68 let file = options.open(path)?;
68 let file = options.open(path.as_ref())?;
6969 #[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 }
7179 Ok(Self {
7280 #[cfg(windows)]
7381 locks: onenote_locks(&file, write)?,
......@@ -255,6 +263,45 @@ pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitEr
255263 io.finish(result)
256264}
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
258305/// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header
259306/// version metadata. No revision is added; reread before committing on `base` again.
260307/// The caller must hold OneNote-compatible exclusion and independently establish which
......@@ -357,8 +404,10 @@ impl Stamp {
357404 })
358405 }
359406
360 /// Reads the header and probes the length, without reading the body.
361 fn check(&self, io: &mut impl CommitIo) -> io::Result<()> {
407 /// Checks that the file `io` reads still has this stamp, reading its header and probing its
408 /// length without reading the body; `ResourceBusy` when it moved on. The caller holds
409 /// OneNote-compatible exclusion for whatever the check guards.
410 pub fn check(&self, io: &mut impl CommitIo) -> io::Result<()> {
362411 let mut header = [0; 1024];
363412 crate::snapshot::read_exact(
364413 &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
615615 } else {
616616 None
617617 };
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;
618642 let checksum = if is_section {
619643 !crc(u32::MAX, &log, file_type)
620644 } else {
......@@ -624,7 +648,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result
624648 log.extend_from_slice(&checksum.to_le_bytes());
625649 log.extend_from_slice(&u64::MAX.to_le_bytes());
626650 log.extend_from_slice(&0_u32.to_le_bytes());
627 let log_chunk = append(&mut output, &log)?;
651 let log_chunk = append(output, &log)?;
628652 output[..16].copy_from_slice(&[
629653 0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0, 0x29, 0x96,
630654 0xd3,
......@@ -648,11 +672,11 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result
648672 output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
649673 }
650674 output[96..100].copy_from_slice(&1_u32.to_le_bytes());
651 output[128..148].copy_from_slice(&placement([0; 16], file_name));
652 for (at, chunk) in hashed_chunk
675 output[128..148].copy_from_slice(&placement);
676 for (at, chunk) in hashed
653677 .map(|chunk| (148, chunk))
654678 .into_iter()
655 .chain([(160, log_chunk), (172, root_chunk)])
679 .chain([(160, log_chunk), (172, root)])
656680 {
657681 output[at..at + 8].copy_from_slice(&chunk.offset.to_le_bytes());
658682 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
662686 output[212..228].copy_from_slice(&fresh_guid()?);
663687 output[228..236].copy_from_slice(&1_u64.to_le_bytes());
664688 output[236..252].copy_from_slice(&fresh_guid()?);
665 let store = Store::parse(&output)?;
666 RevisionIndex::parse(&store)?.validate_current()?;
689 Ok(())
690}
691
692/// A new section file declaring only its root object space `root`, which has no revision
693/// yet, placed as `placement`: what a whole section's revisions are appended to.
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)?;
667717 Ok(output)
668718}
669719
crates/onestore/src/document.rs-5
......@@ -39,11 +39,6 @@ impl<'a> Space<'a> {
3939 pub fn active(&self) -> Option<&Revision<'a>> {
4040 self.revisions.get(self.contexts.get(&ExGuid::default())?)
4141 }
42
43 pub fn into_active(mut self) -> Option<Revision<'a>> {
44 self.revisions
45 .remove(self.contexts.get(&ExGuid::default())?)
46 }
4742}
4843
4944#[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;
1010mod files;
1111mod flush;
1212mod formatting;
13mod identity;
1314mod insertion;
1415mod objects;
1516pub mod op;
......@@ -18,7 +19,6 @@ pub mod page;
1819mod pages;
1920mod paragraph;
2021mod properties;
21#[cfg(feature = "protected")]
2222pub mod protected;
2323mod revisions;
2424mod section;
......@@ -33,10 +33,11 @@ mod write;
3333
3434pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place};
3535#[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};
3737pub use create::{create_empty_section, create_section, create_table_of_contents};
3838pub use files::FileDataReference;
3939pub use formatting::{FONT_SIZES, TextAttribute};
40pub use identity::{place_image, reidentify};
4041pub(crate) use insertion::Insertion;
4142pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
4243pub use outline::OutlineEdit;
crates/onestore/src/objects.rs+10-1
......@@ -190,11 +190,20 @@ impl<'a> Cursor<'a> {
190190
191191impl<'a> RevisionIndex<'a> {
192192 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<()> {
193202 let mut edges = BTreeMap::new();
194203 let mut incoming = BTreeMap::<_, usize>::new();
195204 for (osid, space) in &self.spaces {
196205 for rid in space.labels.values().copied().collect::<BTreeSet<_>>() {
197 let revision = self.resolve(*osid, rid)?;
206 let revision = resolve(*osid, rid)?;
198207 let mut targets = BTreeSet::new();
199208 for oid in revision.reachable()? {
200209 let references = revision.objects[&oid].references()?;
crates/onestore/src/op/content.rs+1-1
......@@ -16,7 +16,7 @@ fn invalid(message: &'static str) -> Error {
1616}
1717
1818/// 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 {
2020 let id = ExGuid { guid, n: 0 }.to_string();
2121 format!("<ifndf>{}", id.split(',').next().unwrap())
2222}
crates/onestore/src/op/lower.rs+1
......@@ -44,6 +44,7 @@ pub fn lower(
4444 identity: None,
4545 created: None,
4646 margin_origin: [0.0; 2],
47 rtl: false,
4748 color: None,
4849 rule_lines: None,
4950 objects: vec![PageObject::Outline(Outline {
crates/onestore/src/page/mod.rs+5
......@@ -159,6 +159,9 @@ pub struct Page {
159159 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
160160 pub created: Option<u64>,
161161 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,
162165 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".
163166 #[serde(default)]
164167 pub color: Option<u32>,
......@@ -814,6 +817,7 @@ impl Page {
814817 let Kind::Page {
815818 margin_origin_x,
816819 margin_origin_y,
820 rtl,
817821 ..
818822 } = &root.kind
819823 else {
......@@ -845,6 +849,7 @@ impl Page {
845849 margin_origin_x.unwrap_or(1.0_f32 * 36.0),
846850 margin_origin_y.unwrap_or(0.4_f32 * 36.0),
847851 ],
852 rtl: rtl.unwrap_or(false),
848853 color: root
849854 .extra
850855 .first()
crates/onestore/src/protected/crypto.rs+122-40
......@@ -89,6 +89,48 @@ fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) {
8989 .expect("block aligned");
9090}
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
92134impl Key {
93135 pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> {
94136 if data.len() > 65536 || password.len() > 65536 {
......@@ -146,47 +188,83 @@ impl Key {
146188 let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?);
147189 let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?);
148190 let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?);
149 let mut hash = Sha1::new();
150 hash.update(salt);
151 for word in password.encode_utf16() {
152 hash.update(word.to_le_bytes());
153 }
154 let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize()));
155 for index in 0..count {
156 let mut hash = Sha1::new();
157 hash.update(index.to_le_bytes());
158 hash.update(seed.as_ref());
159 *seed = hash.finalize().into();
160 }
191 let seed = seed(&salt, password, count);
161192 for (label, bytes) in [
162 (
163 [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79],
164 verifier.as_mut_slice(),
165 ),
166 (
167 [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e],
168 expected.as_mut_slice(),
169 ),
170 (
171 [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6],
172 value.as_mut_slice(),
173 ),
193 (VERIFIER_INPUT, verifier.as_mut_slice()),
194 (VERIFIER_VALUE, expected.as_mut_slice()),
195 (KEY_VALUE, value.as_mut_slice()),
174196 ] {
175 let mut hash = Sha1::new();
176 hash.update(seed.as_ref());
177 hash.update(label);
178 let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize()));
179 decrypt(derived[..16].try_into().unwrap(), &salt, bytes)?;
197 decrypt(&block(&seed, label), &salt, bytes)?;
180198 }
181199 let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice())));
182200 if !bool::from(actual.as_slice().ct_eq(&expected[..20])) {
183201 return Err(Error::PasswordMismatch);
184202 }
185 let mut hash = Sha1::new();
186 hash.update(decoded::<16>(data_key, "saltValue")?);
187 hash.update(0_u32.to_le_bytes());
188 let file_iv = hash.finalize()[..16].try_into().unwrap();
189 Ok(Self { value, file_iv })
203 Ok(Self {
204 value,
205 file_iv: file_iv(&decoded::<16>(data_key, "saltValue")?),
206 })
207 }
208
209 /// A fresh key for `password` and the encryption data that opens it, as OneNote 2010
210 /// writes them: random salts, key and verifier, 100,000 SHA-1 rounds, no integrity block.
211 pub(super) fn create(password: &str) -> Result<(Self, Vec<u8>)> {
212 if password.len() > 65536 {
213 return Err(Error::Limit);
214 }
215 let random = |bytes: &mut [u8]| {
216 getrandom::fill(bytes).map_err(|_| invalid("System random source failed"))
217 };
218 let (mut data_salt, mut salt) = ([0; 16], [0; 16]);
219 let mut value = Zeroizing::new([0; 16]);
220 let mut verifier = Zeroizing::new([0; 16]);
221 random(&mut data_salt)?;
222 random(&mut salt)?;
223 random(value.as_mut_slice())?;
224 random(verifier.as_mut_slice())?;
225 let seed = seed(&salt, password, SPINS);
226 let mut hash = Zeroizing::new([0; 32]);
227 hash[..20].copy_from_slice(&Sha1::digest(verifier.as_slice()));
228 let mut wrapped_value = *value;
229 let mut wrapped_verifier = *verifier;
230 let mut wrapped_hash = *hash;
231 encrypt(&block(&seed, VERIFIER_INPUT), &salt, &mut wrapped_verifier);
232 encrypt(&block(&seed, VERIFIER_VALUE), &salt, &mut wrapped_hash);
233 encrypt(&block(&seed, KEY_VALUE), &salt, &mut wrapped_value);
234 let profile = r#"saltSize="16" blockSize="16" keyBits="128" hashSize="20" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="SHA1""#;
235 let xml = format!(
236 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\
237 <encryption xmlns=\"{NS}\" xmlns:p=\"{PASSWORD_NS}\">\
238 <keyData {profile} saltValue=\"{}\"/>\
239 <keyEncryptors><keyEncryptor uri=\"{PASSWORD_NS}\">\
240 <p:encryptedKey spinCount=\"{SPINS}\" {profile} saltValue=\"{}\" \
241 encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" \
242 encryptedKeyValue=\"{}\"/></keyEncryptor></keyEncryptors></encryption>",
243 STANDARD.encode(data_salt),
244 STANDARD.encode(salt),
245 STANDARD.encode(wrapped_verifier),
246 STANDARD.encode(wrapped_hash),
247 STANDARD.encode(wrapped_value),
248 );
249 let length = u32::try_from(24 + xml.len()).map_err(|_| Error::Limit)?;
250 let mut data = Vec::with_capacity(length as usize);
251 for word in [3, length, 16, length - 16] {
252 data.extend_from_slice(&word.to_le_bytes());
253 }
254 data.extend_from_slice(&[4, 0, 4, 0, 64, 0, 0, 0]);
255 data.extend_from_slice(xml.as_bytes());
256 Ok((
257 Self {
258 value,
259 file_iv: file_iv(&data_salt),
260 },
261 data,
262 ))
263 }
264
265 /// The AES key the section's objects and payloads are encrypted under.
266 pub(super) fn value(&self) -> &[u8; 16] {
267 &self.value
190268 }
191269
192270 pub(super) fn property(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
......@@ -246,17 +324,21 @@ impl Key {
246324 Ok(output)
247325 }
248326
249 /// The stored form of a file payload, the inverse of `file`.
250 pub(super) fn seal_file(&self, clear: &[u8]) -> Vec<u8> {
327 /// The stored form of a file payload, the inverse of `file`: its length, the bytes and
328 /// random padding to the block, as OneNote pads.
329 pub(super) fn seal_file(&self, clear: &[u8]) -> Result<Vec<u8>> {
251330 if clear.is_empty() {
252 return Vec::new();
331 return Ok(Vec::new());
253332 }
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);
255335 output.extend_from_slice(&(clear.len() as u64).to_le_bytes());
256336 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"))?;
258340 encrypt(&self.value, &self.file_iv, &mut output);
259 output
341 Ok(output)
260342 }
261343
262344 pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
crates/onestore/src/protected/mod.rs+370-254
......@@ -1,24 +1,26 @@
1//! Explicit, in-memory opening of native OneNote 2010 protected sections.
2//!
3//! AES-128/CBC and SHA-1 Agile password wrappers are supported. Unknown wrappers
4//! remain opaque through the ordinary storage/document APIs. CBC provides no
5//! general ciphertext authentication; native read-only hashes and model checks
6//! detect structural inconsistencies, not arbitrary changes to all content.
1//! OneNote 2010's password-protected sections: an Office Agile password wrapper
2//! (MS-OFFCRYPTO; SHA-1, 100,000 rounds) around an AES-128 key that encrypts every
3//! property object (CBC, an IV of its own) and payload (CBC, one IV from the key data's
4//! salt). Unknown wrappers stay opaque. CBC authenticates nothing; read-only hashes and
5//! model checks catch structural damage, not every change to content.
76
87mod crypto;
98
109use crate::{
11 ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Transaction,
10 Chunk, ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Store,
1211 document::Document,
13 op::{Edit, Op, OpError},
1412};
13use bumpalo::Bump;
1514use std::{
15 cell::RefCell,
1616 collections::{BTreeMap, BTreeSet},
1717 fmt,
18 sync::Arc,
1819};
1920use zeroize::Zeroizing;
2021
2122type Result<T> = std::result::Result<T, Error>;
23type Format<T> = std::result::Result<T, crate::Error>;
2224
2325#[derive(Debug)]
2426pub enum Error {
......@@ -34,15 +36,34 @@ impl From<crate::Error> for Error {
3436 }
3537}
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
3762impl fmt::Display for Error {
3863 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3964 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"),
4565 Self::Invalid(error) => error.fmt(f),
66 other => f.write_str(other.message()),
4667 }
4768 }
4869}
......@@ -79,17 +100,10 @@ impl Default for Limits {
79100 }
80101}
81102
82struct Decoded<'a> {
83 stored: &'a [u8],
84 clear: Zeroizing<Vec<u8>>,
85}
86
87/// Owns decoded buffers until dropped; returned views cannot outlive this owner.
88///
89/// Passwords are not retained. Dropping this owner clears its derived key and
90/// decoded buffers; a protected save works on ordinary buffers, which are not cleared. Owned strings or exports made from a `Document` are separate
91/// caller-owned copies and must be disposed of by the caller when locking.
92/// Opening never rewrites the source image or changes its protection state.
103/// A protected section's labelled revisions decoded for reading as a `Document`, which
104/// cannot outlive it. Holds no password; dropping it clears its keys and decoded buffers,
105/// while strings and exports made from the `Document` are the caller's to dispose of.
106/// Edits go through `Section::unlock`.
93107///
94108/// ```compile_fail
95109/// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}};
......@@ -103,22 +117,30 @@ struct Decoded<'a> {
103117pub struct UnlockedSection<'a> {
104118 index: &'a RevisionIndex<'a>,
105119 /// By object space and stored address.
106 objects: BTreeMap<(ExGuid, usize), Decoded<'a>>,
120 objects: BTreeMap<(ExGuid, usize), Zeroizing<Vec<u8>>>,
107121 files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>,
108 keys: BTreeMap<&'a [u8], crypto::Key>,
109122}
110123
111124impl<'a> UnlockedSection<'a> {
112125 /// Verifies the password and every labeled revision before exposing a view.
113126 /// Metadata and password input are each bounded to 64 KiB.
114127 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| {
116129 crypto::Key::open(metadata, password, rounds)
117130 })
118131 }
119132
120 /// `open` with each distinct encryption metadata's key from `key`.
121 fn unlock(
133 /// `open` with the key `Key::open` gave, which every space's encryption data must name.
134 pub fn unlock(index: &'a RevisionIndex<'a>, key: &Key, limits: Limits) -> Result<Self> {
135 Self::open_with(index, limits, |metadata, _| {
136 match metadata == &key.metadata[..] {
137 true => Ok(key.key.clone()),
138 false => Err(Error::PasswordMismatch),
139 }
140 })
141 }
142
143 fn open_with(
122144 index: &'a RevisionIndex<'a>,
123145 mut limits: Limits,
124146 mut key: impl FnMut(&[u8], &mut u64) -> Result<crypto::Key>,
......@@ -133,31 +155,10 @@ impl<'a> UnlockedSection<'a> {
133155 index,
134156 objects: BTreeMap::new(),
135157 files: BTreeMap::new(),
136 keys: BTreeMap::new(),
137158 };
138159 let mut keys = BTreeMap::new();
139160 let mut file_keys = BTreeMap::new();
140 let mut hashes = BTreeMap::new();
141 for node in index.store.lists.values().flat_map(|list| &list.nodes) {
142 if !matches!(node.id, 0xc4 | 0xc5) {
143 continue;
144 }
145 let Some(Reference::Data(chunk)) = node.reference else {
146 return Err(invalid("Read-only object has no data reference"));
147 };
148 let bytes = index.store.chunk_data(chunk)?;
149 let expected: [u8; 16] = node
150 .payload
151 .last_chunk::<16>()
152 .copied()
153 .ok_or_else(|| invalid("Missing read-only object hash"))?;
154 if hashes
155 .insert(bytes.as_ptr().addr(), expected)
156 .is_some_and(|old| old != expected)
157 {
158 return Err(invalid("Inconsistent read-only hashes for one payload"));
159 }
160 }
161 let hashes = hashes(index.store)?;
161162 for (space_id, space) in &index.spaces {
162163 let mut metadata = None;
163164 for revision in space.revisions.values() {
......@@ -203,23 +204,10 @@ impl<'a> UnlockedSection<'a> {
203204 .checked_sub(bytes.len())
204205 .ok_or(Error::Limit)?;
205206 let decoded = key.property(bytes)?;
206 if let Some(expected) = expected {
207 let mut hash = md5::Context::new();
208 hash.consume(&decoded);
209 hash.consume(&[0; 7][..(8 - decoded.len() % 8) % 8]);
210 if hash.finalize().0 != *expected {
211 return Err(invalid(
212 "Decrypted read-only object hash mismatch",
213 ));
214 }
207 if expected.is_some_and(|expected| digest(&decoded) != *expected) {
208 return Err(invalid("Decrypted read-only object hash mismatch"));
215209 }
216 result.objects.insert(
217 identity,
218 Decoded {
219 stored: bytes,
220 clear: decoded,
221 },
222 );
210 result.objects.insert(identity, decoded);
223211 }
224212 ObjectData::File { .. } => {
225213 if let Some(FileDataReference::Internal(guid)) =
......@@ -251,7 +239,6 @@ impl<'a> UnlockedSection<'a> {
251239 }
252240 }
253241 }
254 result.keys = keys;
255242 for (space, info) in &index.spaces {
256243 for rid in info.labels.values().copied().collect::<BTreeSet<_>>() {
257244 result.resolve(*space, rid)?.reachable()?;
......@@ -276,7 +263,7 @@ impl<'a> UnlockedSection<'a> {
276263 offset: 0,
277264 message: "Protected object was not decoded",
278265 })?;
279 object.data = ObjectData::Properties(&decoded.clear);
266 object.data = ObjectData::Properties(decoded);
280267 }
281268 }
282269 Ok(revision)
......@@ -299,216 +286,345 @@ impl<'a> UnlockedSection<'a> {
299286 }
300287}
301288
302impl UnlockedSection<'_> {
303 /// A plaintext section holding every object space's current revision and payloads
304 /// under their own identities, for writers that read ordinary sections.
305 fn twin(&self) -> std::result::Result<Zeroizing<Vec<u8>>, crate::Error> {
306 use crate::write::{PropertyObject, RevisionEdit, append_revisions};
307 let copy = |space: ExGuid, revision: ExGuid| {
308 let revision = self.resolve(space, revision)?;
309 // A revision still declares objects its edits detached; a new one holds live ones.
310 let live = revision.reachable()?;
311 let mut objects = BTreeMap::new();
312 for (id, object) in revision.objects.iter().filter(|(id, _)| live.contains(id)) {
313 let copy = match object.data {
314 ObjectData::File {
315 reference,
316 extension,
317 } => {
318 let text = |bytes: &[u8]| {
319 String::from_utf16(
320 &bytes
321 .chunks_exact(2)
322 .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
323 .collect::<Vec<_>>(),
324 )
325 .map_err(|_| crate::Error {
326 offset: 0,
327 message: "Invalid UTF-16 file-data declaration",
328 })
329 };
330 let mut copy =
331 PropertyObject::file(*id, &text(reference)?, &text(extension)?)?;
332 copy.jcid = object.jcid;
333 copy.global_ids = std::sync::Arc::clone(&object.global_ids);
334 copy
335 }
336 _ => PropertyObject::from_object(object)?,
337 };
338 objects.insert(*id, copy);
339 }
340 Ok::<_, crate::Error>((revision.roots, objects))
341 };
342 let payloads: Vec<_> = self
343 .files
344 .iter()
345 .map(|(id, bytes)| (*id, bytes.as_slice()))
346 .collect();
347 let current = (ExGuid::default(), 1);
348 // The scaffold's root space takes the section's identity, then its content.
349 let mut scaffold = crate::create_section("twin.one", "", "")?;
350 let identity = |id: ExGuid| {
351 let mut bytes = Vec::new();
352 id.encode(&mut bytes);
353 bytes
354 };
355 let store = crate::Store::parse(&scaffold)?;
356 let placeholder = identity(RevisionIndex::parse(&store)?.root);
357 for at in 0..=scaffold.len() - 20 {
358 if scaffold[at..at + 20] == placeholder {
359 scaffold[at..at + 20].copy_from_slice(&identity(self.index.root));
289/// A protected section's key, opened with its password or made for a new one. Holds no
290/// password; the key is cleared when its last clone drops.
291#[derive(Clone)]
292pub struct Key {
293 key: crypto::Key,
294 /// The encryption data (MS-ONESTORE 2.5.19) that opens the key.
295 metadata: Arc<[u8]>,
296}
297
298impl fmt::Debug for Key {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300 f.write_str("Key")
301 }
302}
303
304impl Key {
305 /// Opens the key of the protected section `image` with `password`.
306 pub fn open(image: &[u8], password: &str) -> Result<Self> {
307 let store = Store::parse(image)?;
308 let metadata = metadata(&store)?.ok_or(Error::Unsupported)?;
309 Ok(Self {
310 key: crypto::Key::open(metadata, password, &mut Limits::default().kdf_rounds)?,
311 metadata: Arc::from(metadata),
312 })
313 }
314
315 /// A fresh key for `password`, with OneNote 2010's encryption data.
316 pub fn new(password: &str) -> Result<Self> {
317 let (key, metadata) = crypto::Key::create(password)?;
318 Ok(Self {
319 key,
320 metadata: Arc::from(metadata),
321 })
322 }
323
324 /// The AES key the section's content is encrypted under, for keys derived from it.
325 pub fn secret(&self) -> &[u8; 16] {
326 self.key.value()
327 }
328
329 pub(crate) fn metadata(&self) -> &[u8] {
330 &self.metadata
331 }
332}
333
334/// The section `image` written anew, under `new` or in the clear, as OneNote 2010 rewrites
335/// a section whose password it sets, changes or removes: the file and its object spaces
336/// take new identities, each space keeps its labelled revisions, every one a checkpoint,
337/// and the payloads come along. A protected `image` opens under `key`.
338pub fn rekey(image: &[u8], key: Option<&Key>, new: Option<&Key>) -> Result<Vec<u8>> {
339 let store = Store::parse(image)?;
340 if !store.checksum_mismatches.is_empty() {
341 return Err(invalid(
342 "Cannot write a file with transaction checksum damage",
343 ));
344 }
345 let index = RevisionIndex::parse(&store)?;
346 let opened = match (key, metadata(&store)?) {
347 (Some(key), Some(_)) => Some(Opened::new(&store, key)?),
348 (None, None) => None,
349 (None, Some(_)) => return Err(Error::PasswordMismatch),
350 (Some(_), None) => return Err(invalid("The section is not protected")),
351 };
352 let arena = Bump::new();
353 let mut fresh = BTreeMap::new();
354 for space in index.spaces.keys() {
355 if let std::collections::btree_map::Entry::Vacant(entry) = fresh.entry(space.guid) {
356 entry.insert(crate::write::fresh_guid()?);
357 }
358 }
359 // OneNote gives the payloads new identities too.
360 let mut files = BTreeMap::new();
361 for guid in crate::write::declared_payloads(&store) {
362 files.insert(guid, crate::write::fresh_guid()?);
363 }
364 let rename = |id: ExGuid| ExGuid {
365 guid: fresh.get(&id.guid).copied().unwrap_or(id.guid),
366 n: id.n,
367 };
368 let mut spaces = Vec::new();
369 for (space, info) in &index.spaces {
370 let mut labelled: Vec<(ExGuid, Vec<(ExGuid, u32)>)> = Vec::new();
371 // The current revision first, as OneNote writes it.
372 let mut labels: Vec<_> = info.labels.iter().collect();
373 labels.sort_by_key(|(label, _)| **label != (ExGuid::default(), 1));
374 for (label, rid) in labels {
375 match labelled.iter_mut().find(|(known, _)| known == rid) {
376 Some((_, names)) => names.push(*label),
377 None => labelled.push((*rid, vec![*label])),
360378 }
361379 }
362 let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| {
363 let mut spaces = BTreeMap::new();
364 for id in self.index.spaces.keys() {
365 let (roots, objects) = copy(*id, self.index.active(*id)?)?;
366 let edit = if *id == self.index.root {
367 RevisionEdit::Label {
368 context: current.0,
369 role: current.1,
370 roots,
371 objects,
380 let mut revisions = Vec::new();
381 for (rid, labels) in labelled {
382 let mut resolved = index.resolve(*space, rid)?;
383 if let Some(opened) = &opened {
384 for object in resolved.objects.values_mut() {
385 if let ObjectData::Encrypted(stored) = object.data {
386 object.data = ObjectData::Properties(opened.property(&arena, stored)?);
372387 }
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 );
396388 }
397389 }
398 if spaces.is_empty() {
399 break;
390 let mut objects = BTreeMap::new();
391 for id in resolved.reachable()? {
392 let mut object = resolved.objects[&id].clone();
393 if let (Some(FileDataReference::Internal(guid)), ObjectData::File { extension, .. }) =
394 (object.file_reference()?, object.data)
395 && let Some(renamed) = files.get(&guid)
396 {
397 let reference: Vec<u8> = crate::op::content::payload_reference(*renamed)
398 .encode_utf16()
399 .flat_map(u16::to_le_bytes)
400 .collect();
401 object.data = ObjectData::File {
402 reference: arena.alloc_slice_copy(&reference),
403 extension,
404 };
405 }
406 object.global_ids = Arc::new(
407 object
408 .global_ids
409 .iter()
410 .map(|(entry, guid)| (*entry, fresh.get(guid).copied().unwrap_or(*guid)))
411 .collect(),
412 );
413 objects.insert(rename(id), object);
400414 }
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 });
402424 }
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));
404435 }
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)
405445}
406446
407impl UnlockedSection<'_> {
408 /// Applies `edit` to this section as `Section::apply` applies it to an ordinary one and
409 /// seals the result as one revision per changed space, stored under the section's key.
410 /// Page ops only: a protected section's page list is not edited.
411 pub fn apply(&self, author: &str, edit: &Edit) -> std::result::Result<Transaction, OpError> {
412 let failed = |error: crate::Error| OpError::Failed(error);
413 if self.keys.len() != 1 {
414 return Err(OpError::Unsupported(
415 "Only a section under one key takes edits",
416 ));
447/// The encryption data every revision of `store` names; none in a section without any.
448fn metadata<'a>(store: &Store<'a>) -> Result<Option<&'a [u8]>> {
449 let mut found = None;
450 for node in store.lists.values().flat_map(|list| &list.nodes) {
451 if node.id != 0x7c {
452 continue;
417453 }
418 if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) {
419 return Err(OpError::Unsupported(
420 "A protected section's page list is not edited",
421 ));
454 let Some(Reference::Data(chunk)) = node.reference else {
455 return Err(invalid("Missing encryption key reference"));
456 };
457 let data = store.encryption_key(chunk)?;
458 if found.replace(data).is_some_and(|old| old != data) {
459 return Err(Error::Unsupported);
460 }
461 }
462 Ok(found)
463}
464
465/// The hashes read-only declarations give their stored bytes, by address.
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);
422516 }
423 let twin = self.twin().map_err(failed)?;
424 let arena = crate::Arena::default();
425 let mut section = crate::Section::open(&arena, twin.to_vec()).map_err(failed)?;
426 section.apply(author, edit)?;
427 section.seal().map_err(failed)?;
428 let applied = Zeroizing::new(section.image());
429 drop(section);
430 let applied_store = crate::Store::parse(&applied).map_err(failed)?;
431 let applied_index = RevisionIndex::parse(&applied_store).map_err(failed)?;
432 // The twin's scaffold spaces are not the section's.
433 let edited = self
434 .index
517 let index = RevisionIndex::parse(store)?;
518 if index
435519 .spaces
436 .keys()
437 .map(|space| Ok((*space, applied_index.resolve_active(*space)?)))
438 .collect::<std::result::Result<Vec<_>, crate::Error>>()
439 .map_err(failed)?;
440 let edited: Vec<_> = edited
441 .iter()
442 .map(|(space, after)| (*space, after))
443 .collect();
444 let payloads = crate::write::declared_payloads(&applied_store)
445 .into_iter()
446 .map(|guid| Ok((guid, applied_store.file_data(guid)?)))
447 .collect::<std::result::Result<Vec<_>, crate::Error>>()
448 .map_err(failed)?;
449 let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self))
450 .map_err(failed)?
451 .ok_or(OpError::Unsupported("The edit changes nothing"))?;
452 let written =
453 crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?;
454 let store = crate::Store::parse(&written).map_err(failed)?;
455 let index = RevisionIndex::parse(&store).map_err(failed)?;
456 UnlockedSection::unlock(&index, Limits::default(), |metadata, _| {
457 self.keys
458 .get(metadata)
459 .cloned()
460 .ok_or(Error::PasswordMismatch)
520 .values()
521 .flat_map(|space| space.revisions.values())
522 .any(|revision| !revision.encrypted)
523 {
524 return Err(invalid(
525 "A protected section stores a revision in the clear",
526 ));
527 }
528 let chunk = store
529 .lists
530 .values()
531 .flat_map(|list| &list.nodes)
532 .find_map(|node| match node.reference {
533 Some(Reference::Data(chunk)) if node.id == 0x7c => Some(chunk),
534 _ => None,
535 })
536 .ok_or(Error::Unsupported)?;
537 Ok(Self {
538 key: key.clone(),
539 chunk,
540 hashes: hashes(store)?,
541 clear: RefCell::default(),
542 stored: RefCell::default(),
461543 })
462 .map_err(|error| match error {
463 Error::Invalid(error) => failed(error),
464 _ => failed(crate::Error {
544 }
545
546 /// `key` for writing a new image whose encryption data lies at `chunk`.
547 pub(crate) fn sealing(key: &Key, chunk: Chunk) -> Self {
548 Self {
549 key: key.clone(),
550 chunk,
551 hashes: BTreeMap::new(),
552 clear: RefCell::default(),
553 stored: RefCell::default(),
554 }
555 }
556
557 /// The clear bytes of a stored object, decoded once into `arena`.
558 pub(crate) fn property(&self, arena: &'a Bump, stored: &'a [u8]) -> Format<&'a [u8]> {
559 if let Some(clear) = self.clear.borrow().get(&stored.as_ptr().addr()) {
560 return Ok(clear);
561 }
562 let decoded = self.key.key.property(stored).map_err(crate::Error::from)?;
563 if let Some(expected) = self.hashes.get(&stored.as_ptr().addr())
564 && digest(&decoded) != *expected
565 {
566 return Err(crate::Error {
465567 offset: 0,
466 message: "The written section does not unlock under its key",
467 }),
468 })?;
469 Ok(transaction)
568 message: "Decrypted read-only object hash mismatch",
569 });
570 }
571 let clear: &'a [u8] = arena.alloc_slice_copy(&decoded);
572 self.sealed(clear, stored);
573 Ok(clear)
470574 }
471}
472575
473impl UnlockedSection<'_> {
474 /// The section key; `apply` admits sections with exactly one.
475 fn key(&self) -> &crypto::Key {
476 self.keys.values().next().expect("one section key")
576 /// The clear bytes of a stored payload, in `arena`.
577 pub(crate) fn file(&self, arena: &'a Bump, stored: &[u8]) -> Format<&'a [u8]> {
578 Ok(arena.alloc_slice_copy(&self.key.key.file(stored).map_err(crate::Error::from)?))
477579 }
478}
479580
480impl crate::write::Protection for UnlockedSection<'_> {
481 fn resolve(
482 &self,
483 space: ExGuid,
484 revision: ExGuid,
485 ) -> std::result::Result<crate::ResolvedRevision<'_>, crate::Error> {
486 self.resolve(space, revision)
581 pub(crate) fn key(&self) -> &Key {
582 &self.key
487583 }
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<'_> {
489597 fn stored(&self, clear: &[u8]) -> Option<&[u8]> {
490 self.objects
491 .values()
492 .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr()))
493 .map(|decoded| decoded.stored)
598 self.stored.borrow().get(&clear.as_ptr().addr()).copied()
494599 }
495600
496 fn seal_property(&self, clear: &[u8]) -> std::result::Result<Vec<u8>, crate::Error> {
497 let random = crate::Error {
498 offset: 0,
499 message: "System random source failed",
500 };
501 let mut iv = [0; 16];
502 getrandom::fill(&mut iv).map_err(|_| random)?;
503 self.key()
504 .seal_property(clear, iv)
505 .map_err(|error| match error {
506 Error::Invalid(error) => error,
507 _ => random,
508 })
601 fn seal_property(&self, clear: &[u8]) -> Format<Vec<u8>> {
602 seal_property(&self.key, clear)
603 }
604
605 fn seal_file(&self, clear: &[u8]) -> Format<Vec<u8>> {
606 self.key.key.seal_file(clear).map_err(crate::Error::from)
607 }
608
609 fn open_property(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> {
610 self.key.key.property(stored).map_err(crate::Error::from)
509611 }
510612
511 fn seal_file(&self, clear: &[u8]) -> Vec<u8> {
512 self.key().seal_file(clear)
613 fn open_file(&self, stored: &[u8]) -> Format<Zeroizing<Vec<u8>>> {
614 self.key.key.file(stored).map_err(crate::Error::from)
513615 }
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)
514630}
crates/onestore/src/section.rs+133-77
......@@ -7,9 +7,10 @@ use crate::{
77 active::{ActivePage, Changes, Files},
88 document::Revision,
99 page::Page,
10 protected::{Key, Opened},
1011 store::StoreState,
1112 write::{
12 Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth,
13 Appending, Commit, LiveRevision, PropertyObject, Protection, Sealing, Written, chain_depth,
1314 check_transaction, declared, label_payload, node, replacement, unchanged,
1415 },
1516};
......@@ -34,8 +35,8 @@ pub struct Section<'a> {
3435 /// The in-place writes of the sealed transactions, in order.
3536 patches: Vec<(u64, Vec<u8>)>,
3637 state: StoreState,
37 /// Payloads the opened images embed, by identity, with the image holding each.
38 files: &'a [([u8; 16], Chunk, &'a [u8])],
38 /// Payloads the opened images embed, by identity, as they read.
39 files: &'a [([u8; 16], Result<&'a [u8]>)],
3940 /// Payload identities the file-data store declares.
4041 declared: BTreeSet<[u8; 16]>,
4142 spaces: BTreeMap<ExGuid, Space<'a>>,
......@@ -48,6 +49,8 @@ pub struct Section<'a> {
4849 root: ExGuid,
4950 /// While an edit applies, the spaces it changed as they were before it.
5051 undo: Option<Undo<'a>>,
52 /// A password-protected section's key and what it decoded.
53 unlocked: Option<Opened<'a>>,
5154}
5255
5356#[derive(Default)]
......@@ -139,9 +142,17 @@ struct Open<'a> {
139142impl<'a> Section<'a> {
140143 /// Parses and fully validates a section image.
141144 pub fn open(arena: &'a Arena, image: Vec<u8>) -> Result<Self> {
142 Self::open_with(arena, vec![image], |_, space| {
143 space.labels.get(&(ExGuid::default(), 1)).copied()
144 })
145 Ok(Self::open_with(arena, vec![image], None, current)?)
146 }
147
148 /// `open` for a password-protected section, decoding it under `key`, which edits are
149 /// sealed under too. Decoded bytes live in `arena` until it drops.
150 pub fn unlock(
151 arena: &'a Arena,
152 image: Vec<u8>,
153 key: &Key,
154 ) -> std::result::Result<Self, crate::protected::Error> {
155 Self::open_with(arena, vec![image], Some(key), current)
145156 }
146157
147158 /// Opens an earlier state of a section: each object space in `revisions` at the revision
......@@ -153,7 +164,7 @@ impl<'a> Section<'a> {
153164 images: Vec<Vec<u8>>,
154165 revisions: &BTreeMap<ExGuid, ExGuid>,
155166 ) -> Result<Self> {
156 let section = Self::open_with(arena, images, |id, space| {
167 let section = Self::open_with(arena, images, None, |id, space| {
157168 revisions
158169 .get(id)
159170 .copied()
......@@ -170,15 +181,17 @@ impl<'a> Section<'a> {
170181
171182 /// `open`, each object space at the revision `revision` chooses in the first image where it
172183 /// 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`.
174185 fn open_with(
175186 arena: &'a Arena,
176187 images: Vec<Vec<u8>>,
188 key: Option<&Key>,
177189 revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option<ExGuid>,
178 ) -> Result<Self> {
190 ) -> std::result::Result<Self, crate::protected::Error> {
179191 let mut opened = None;
192 let mut unlocked = None;
180193 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();
182195 for image in images {
183196 let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image);
184197 drop(image);
......@@ -187,28 +200,40 @@ impl<'a> Section<'a> {
187200 return Err(Error {
188201 offset: *offset,
189202 message: "Cannot write a file with transaction checksum damage",
190 });
203 }
204 .into());
191205 }
192206 if store.header.file_type != FileType::Section {
193207 return Err(Error {
194208 offset: 0,
195209 message: "Choose a section file",
196 });
210 }
211 .into());
197212 }
198213 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)?))?;
200225 for (id, space) in &index.spaces {
201226 let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else {
202227 continue;
203228 };
204 let revision = bound(bytes, index.resolve(*id, rid)?);
229 let revision = bound(index.resolve(*id, rid)?)?;
205230 let history = space
206231 .labels
207232 .get(&(versions::HISTORY, 1))
208233 .map(|history| -> Result<History<'a>> {
209234 Ok(History {
210235 rid: *history,
211 revision: bound(bytes, index.resolve(*id, *history)?),
236 revision: bound(index.resolve(*id, *history)?)?,
212237 depth: chain_depth(&index, *id, *history),
213238 pending: BTreeMap::new(),
214239 })
......@@ -231,11 +256,24 @@ impl<'a> Section<'a> {
231256 return Err(Error {
232257 offset: node.offset,
233258 message: "File-data object lacks a data reference",
234 });
259 }
260 .into());
235261 };
236262 // A payload an earlier image declares is the same bytes: it names them.
237263 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));
239277 }
240278 }
241279 if opened.is_none() {
......@@ -259,6 +297,7 @@ impl<'a> Section<'a> {
259297 broken: false,
260298 root,
261299 undo: None,
300 unlocked,
262301 })
263302 }
264303
......@@ -479,25 +518,14 @@ impl<'a> Section<'a> {
479518 fn files(&self) -> Files<'a> {
480519 let files = self.files;
481520 std::rc::Rc::new(move |guid| {
482 let start = files.partition_point(|(id, ..)| *id < guid);
483 match files[start..]
484 .iter()
485 .take_while(|(id, ..)| *id == guid)
486 .count()
487 {
488 0 => Err(Error {
521 let start = files.partition_point(|(id, _)| *id < guid);
522 let mut declared = files[start..].iter().take_while(|(id, _)| *id == guid);
523 match (declared.next(), declared.next()) {
524 (None, _) => Err(Error {
489525 offset: 0,
490526 message: "File-data object is not declared",
491527 }),
492 1 => {
493 let (_, chunk, bytes) = files[start];
494 let (offset, length) = (chunk.offset as usize, chunk.length as usize);
495 let blob = bytes.get(offset..offset + length).ok_or(Error {
496 offset,
497 message: "Chunk extends outside the data area",
498 })?;
499 crate::files::payload(blob, offset)
500 }
528 (Some((_, payload)), None) => *payload,
501529 _ => Err(Error {
502530 offset: 0,
503531 message: "Duplicate file-data identity",
......@@ -706,6 +734,10 @@ impl<'a> Section<'a> {
706734 }
707735 self.broken = true;
708736 let arena = &self.arena.0;
737 let protection = self
738 .unlocked
739 .as_ref()
740 .map(|unlocked| unlocked as &dyn Protection);
709741 let mut appending = Appending::new(self.state.clone());
710742 let mut sealed = Vec::new();
711743 for (id, space) in &mut self.spaces {
......@@ -726,8 +758,7 @@ impl<'a> Section<'a> {
726758 created: &frozen.revised.created,
727759 },
728760 &self.segments,
729 None,
730 None,
761 protection,
731762 &mut manifest,
732763 )?;
733764 }
......@@ -757,8 +788,7 @@ impl<'a> Section<'a> {
757788 created: &created,
758789 },
759790 &self.segments,
760 None,
761 None,
791 protection,
762792 &mut manifest,
763793 )?;
764794 if commit.checkpoint {
......@@ -805,8 +835,7 @@ impl<'a> Section<'a> {
805835 created: &created,
806836 },
807837 &self.segments,
808 None,
809 None,
838 protection,
810839 &mut manifest,
811840 )?;
812841 if commit.checkpoint {
......@@ -834,7 +863,7 @@ impl<'a> Section<'a> {
834863 .filter(|(guid, _)| !self.declared.contains(guid))
835864 .copied()
836865 .collect();
837 appending.payloads(&payloads, None)?;
866 appending.payloads(&payloads, protection)?;
838867 #[cfg_attr(not(test), expect(unused_mut))]
839868 let mut transaction = appending.finish()?;
840869 #[cfg(test)]
......@@ -892,6 +921,7 @@ impl<'a> Section<'a> {
892921 &written,
893922 &labels,
894923 &payloads,
924 protection,
895925 )?;
896926 let base = transaction.base.length;
897927 let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append);
......@@ -899,11 +929,20 @@ impl<'a> Section<'a> {
899929 self.patches.extend(transaction.patches.iter().cloned());
900930 self.state = state.clone();
901931 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)]| {
903936 for (object_id, chunk) in stored {
904937 let start = (chunk.offset - base) as usize;
905 revision.objects.get_mut(object_id).unwrap().data =
906 ObjectData::Properties(&segment[start..start + chunk.length as usize]);
938 let bytes = &segment[start..start + chunk.length as usize];
939 let data = &mut revision.objects.get_mut(object_id).unwrap().data;
940 match (unlocked, *data) {
941 (Some(unlocked), ObjectData::Properties(clear)) => {
942 unlocked.sealed(clear, bytes)
943 }
944 _ => *data = ObjectData::Properties(bytes),
945 }
907946 }
908947 };
909948 for (id, page, history) in &mut sealed {
......@@ -916,11 +955,11 @@ impl<'a> Section<'a> {
916955 space.rid = Some(*rid);
917956 space.restored = None;
918957 space.newest = Some(*rid);
919 bind(&mut open.file.revision, stored);
958 rebind(&mut open.file.revision, stored);
920959 }
921960 if let Some((rid, stored, live, _)) = history.take() {
922961 let mut revision = live.revision;
923 bind(&mut revision, &stored);
962 rebind(&mut revision, &stored);
924963 space.history = Some(History {
925964 rid,
926965 revision,
......@@ -969,7 +1008,11 @@ impl<'a> Section<'a> {
9691008 }
9701009 let mut image = self.image();
9711010 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)?;
9731016 Ok(())
9741017 }
9751018}
......@@ -1022,37 +1065,50 @@ fn changed<'a>(
10221065 }))
10231066}
10241067
1025/// `resolved`, parsed from `bytes`, borrowing them for their lifetime.
1026fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> {
1027 ResolvedRevision {
1028 roots: resolved.roots,
1029 objects: resolved
1030 .objects
1031 .into_iter()
1032 .map(|(id, object)| {
1033 // The parse borrows `bytes` only as long as its store; its slices lie in
1034 // `bytes`, which the arena keeps.
1035 let data = match object.data {
1036 ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)),
1037 ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)),
1038 ObjectData::File {
1039 reference,
1040 extension,
1041 } => ObjectData::File {
1042 reference: within(bytes, reference),
1043 extension: within(bytes, extension),
1044 },
1045 };
1046 let object = crate::Object {
1047 jcid: object.jcid,
1048 reference_count: object.reference_count,
1049 data,
1050 global_ids: object.global_ids,
1051 };
1052 (id, object)
1053 })
1054 .collect(),
1068/// `resolved` with every slice moved into bytes that live as long as the section by `bytes`,
1069/// a protected section's objects decoded under `unlocked`.
1070fn bind<'a>(
1071 arena: &'a Arena,
1072 unlocked: Option<&Opened<'a>>,
1073 resolved: ResolvedRevision<'_>,
1074 bytes: impl Fn(&[u8]) -> Result<&'a [u8]>,
1075) -> Result<ResolvedRevision<'a>> {
1076 let mut objects = BTreeMap::new();
1077 for (id, object) in resolved.objects {
1078 let data = match (object.data, unlocked) {
1079 (ObjectData::Properties(data), _) => ObjectData::Properties(bytes(data)?),
1080 (ObjectData::Encrypted(data), Some(unlocked)) => {
1081 ObjectData::Properties(unlocked.property(&arena.0, bytes(data)?)?)
1082 }
1083 (ObjectData::Encrypted(data), None) => ObjectData::Encrypted(bytes(data)?),
1084 (
1085 ObjectData::File {
1086 reference,
1087 extension,
1088 },
1089 _,
1090 ) => ObjectData::File {
1091 reference: bytes(reference)?,
1092 extension: bytes(extension)?,
1093 },
1094 };
1095 let object = crate::Object {
1096 jcid: object.jcid,
1097 reference_count: object.reference_count,
1098 data,
1099 global_ids: object.global_ids,
1100 };
1101 objects.insert(id, object);
10551102 }
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()
10561112}
10571113
10581114/// `part`, a slice of `image`, with the image's lifetime.
......@@ -1097,7 +1153,7 @@ mod tests {
10971153 edited: &[(ExGuid, &ResolvedRevision<'_>)],
10981154 payloads: &[([u8; 16], &[u8])],
10991155 ) -> Vec<u8> {
1100 let transaction = crate::write::squash(index, edited, payloads, None).unwrap();
1156 let transaction = crate::write::squash(index, edited, payloads).unwrap();
11011157 crate::write::applied(index.store.data, transaction.as_ref()).unwrap()
11021158 }
11031159
crates/onestore/src/section/versions.rs+19-32
......@@ -6,7 +6,7 @@
66
77use super::{Frozen, Open, Section, SpaceState, changed, one_page};
88use crate::{
9 Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store,
9 Error, ExGuid, FileType, Object, ResolvedRevision, RevisionIndex, Store,
1010 active::{ActivePage, manifest_pages},
1111 create::{author_properties, current_timestamps, properties},
1212 document::{Element, Kind, Revision},
......@@ -144,8 +144,13 @@ impl<'a> Section<'a> {
144144 let image = self.image();
145145 let store = Store::parse(&image)?;
146146 let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?;
147 let resolved = self.resident(&image, resolved)?;
147148 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 }
149154 })?;
150155 one_page(&view, &manifest_pages(&view))
151156 }
......@@ -258,8 +263,17 @@ impl<'a> Section<'a> {
258263 let store = Store::parse(&image)?;
259264 let index = RevisionIndex::parse(&store)?;
260265 let rid = self.version_revision(&index, space, context)?;
261 let resolved = index.resolve(space, rid)?;
262 // Object bytes lie in the section's segments, which outlive this image.
266 let revision = self.resident(&image, index.resolve(space, rid)?)?;
267 Ok((rid, revision, chain_depth(&index, space, rid)))
268 }
269
270 /// `resolved`, read from `image`, the section's sealed image, with its bytes in the
271 /// section's segments, which outlive the image; a protected section's decoded.
272 fn resident(
273 &self,
274 image: &[u8],
275 resolved: ResolvedRevision<'_>,
276 ) -> Result<ResolvedRevision<'a>> {
263277 let resident = |part: &[u8]| -> Result<&'a [u8]> {
264278 let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64;
265279 self.segments
......@@ -275,34 +289,7 @@ impl<'a> Section<'a> {
275289 message: "A version's bytes lie outside the section",
276290 })
277291 };
278 let mut objects = BTreeMap::new();
279 for (id, object) in resolved.objects {
280 let data = match object.data {
281 ObjectData::Properties(data) => ObjectData::Properties(resident(data)?),
282 ObjectData::Encrypted(data) => ObjectData::Encrypted(resident(data)?),
283 ObjectData::File {
284 reference,
285 extension,
286 } => ObjectData::File {
287 reference: resident(reference)?,
288 extension: resident(extension)?,
289 },
290 };
291 objects.insert(
292 id,
293 Object {
294 jcid: object.jcid,
295 reference_count: object.reference_count,
296 data,
297 global_ids: object.global_ids,
298 },
299 );
300 }
301 let revision = ResolvedRevision {
302 roots: resolved.roots,
303 objects,
304 };
305 Ok((rid, revision, chain_depth(&index, space, rid)))
292 super::bind(self.arena, self.unlocked.as_ref(), resolved, resident)
306293 }
307294
308295 /// 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 {
3535 Remove {
3636 identity: [u8; 16],
3737 },
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 },
3844}
3945
4046fn component(name: &str) -> bool {
......@@ -87,6 +93,7 @@ pub fn edit_table_of_contents(
8793 stored.into_iter().map(|(_, entry)| entry).collect();
8894 let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new();
8995 let mut renamed = BTreeSet::new();
96 let mut reidentified = BTreeSet::new();
9097 let mut color = None;
9198 for edit in edits {
9299 let position = |identity: &[u8; 16]| {
......@@ -178,6 +185,14 @@ pub fn edit_table_of_contents(
178185 let (id, _, _) = listed.remove(at);
179186 created.remove(&id);
180187 }
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 }
181196 }
182197 }
183198 let listed = listed;
......@@ -188,12 +203,12 @@ pub fn edit_table_of_contents(
188203 TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. }
189204 )
190205 });
191 let transaction = build_on(&index, &[], None, |index| {
206 let transaction = build_on(&index, &[], |index| {
192207 let raw = index.resolve_active(space)?;
193208 let mut changed = BTreeMap::new();
194209 let mut root_object = PropertyObject::from_object(&raw.objects[&root])?;
195210 let mut references = Vec::new();
196 for (order, (id, _, filename)) in listed.iter().enumerate() {
211 for (order, (id, identity, filename)) in listed.iter().enumerate() {
197212 let mut object = match created.remove(id) {
198213 Some(object) => object,
199214 None => PropertyObject::from_object(&raw.objects[id])?,
......@@ -213,6 +228,9 @@ pub fn edit_table_of_contents(
213228 if renamed.contains(id) {
214229 updates.push((0x1c001d6b, crate::create::string(filename)));
215230 }
231 if reidentified.contains(id) {
232 updates.push((0x1c001d94, identity.to_vec()));
233 }
216234 let updates: Vec<(u32, &[u8])> = updates
217235 .iter()
218236 .map(|(id, bytes)| (*id, bytes.as_slice()))
......@@ -287,7 +305,7 @@ mod tests {
287305 let index = RevisionIndex::parse(&store).unwrap();
288306 let space = Document::parse(&index).unwrap().root;
289307 let listed = entries(&toc);
290 let transaction = build_on(&index, &[], None, |index| {
308 let transaction = build_on(&index, &[], |index| {
291309 let raw = index.resolve_active(space)?;
292310 let mut changed = BTreeMap::new();
293311 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 {
487487
488488pub(crate) enum RevisionEdit {
489489 Update(BTreeMap<ExGuid, PropertyObject>),
490 #[cfg(any(test, feature = "protected"))]
490 #[cfg(test)]
491491 Create {
492492 roots: BTreeMap<u32, ExGuid>,
493493 objects: BTreeMap<ExGuid, PropertyObject>,
494494 },
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 },
503495}
504496
505497impl PropertyObject {
......@@ -801,14 +793,16 @@ impl PropertyObject {
801793 }
802794}
803795
804/// A password-protected section's unlocked view, through which its revisions are
805/// read as plaintext and written back in their stored form.
796/// A password-protected section's key, under which a writer stores what it appends.
806797pub(crate) trait Protection {
807 fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result<crate::ResolvedRevision<'_>>;
808 /// The stored bytes a resolved object's plaintext was decoded from.
798 /// The stored bytes an object's clear bytes were decoded from.
809799 fn stored(&self, clear: &[u8]) -> Option<&[u8]>;
810800 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;
812806}
813807
814808/// The global-ID entries a group of objects declared together uses: each object's own
......@@ -1281,7 +1275,7 @@ pub(crate) fn revisions(
12811275 let store = Store::parse(source)?;
12821276 let index = RevisionIndex::parse(&store)?;
12831277 index.validate_current()?;
1284 build_on(&index, &[], None, edit)
1278 build_on(&index, &[], edit)
12851279}
12861280
12871281/// A test fixture: `source` with `edit`'s revisions appended.
......@@ -1290,32 +1284,17 @@ pub(crate) fn write_revisions(
12901284 source: &[u8],
12911285 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
12921286) -> Result<Vec<u8>> {
1293 publish(source, &[], None, true, edit)
1294}
1295
1296/// `source` with `edit`'s revisions and the `payloads` appended, without validating that
1297/// current revisions are complete: a protected section is validated by unlocking it,
1298/// through `protection`.
1299#[cfg(feature = "protected")]
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)
1287 publish(source, true, edit)
13071288}
13081289
1309#[cfg(any(test, feature = "protected"))]
1290#[cfg(test)]
13101291fn publish(
13111292 source: &[u8],
1312 payloads: &[([u8; 16], &[u8])],
1313 protection: Option<&dyn Protection>,
13141293 validate: bool,
13151294 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
13161295) -> Result<Vec<u8>> {
13171296 // 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)?;
13191298 check(&output, validate)?;
13201299 Ok(output)
13211300}
......@@ -1332,11 +1311,9 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> {
13321311
13331312/// The written image, unchecked: `check` follows once the caller has released whatever
13341313/// `edit` borrowed.
1335#[cfg(any(test, feature = "protected"))]
1314#[cfg(test)]
13361315fn build(
13371316 source: &[u8],
1338 payloads: &[([u8; 16], &[u8])],
1339 protection: Option<&dyn Protection>,
13401317 validate: bool,
13411318 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
13421319) -> Result<Vec<u8>> {
......@@ -1345,10 +1322,7 @@ fn build(
13451322 if validate {
13461323 index.validate_current()?;
13471324 }
1348 applied(
1349 source,
1350 build_on(&index, payloads, protection, edit)?.as_ref(),
1351 )
1325 applied(source, build_on(&index, &[], edit)?.as_ref())
13521326}
13531327
13541328/// `write_revision` on a source the caller has parsed and validated.
......@@ -1360,7 +1334,7 @@ pub(crate) fn write_revision_on(
13601334) -> Result<Vec<u8>> {
13611335 let output = applied(
13621336 index.store.data,
1363 build_on(index, &[], None, update(space, edit))?.as_ref(),
1337 build_on(index, &[], update(space, edit))?.as_ref(),
13641338 )?;
13651339 check(&output, true)?;
13661340 Ok(output)
......@@ -1377,7 +1351,6 @@ thread_local! {
13771351pub(crate) fn build_on(
13781352 index: &RevisionIndex<'_>,
13791353 payloads: &[([u8; 16], &[u8])],
1380 protection: Option<&dyn Protection>,
13811354 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
13821355) -> Result<Option<crate::Transaction>> {
13831356 #[cfg(test)]
......@@ -1391,10 +1364,6 @@ pub(crate) fn build_on(
13911364 message: "Cannot write a file with transaction checksum damage",
13921365 });
13931366 }
1394 let resolve = |space, rid| match protection {
1395 Some(protection) => protection.resolve(space, rid),
1396 None => index.resolve(space, rid),
1397 };
13981367 let changes = edit(index)?;
13991368 let mut appending = Appending::new(store.state()?);
14001369 for (space, change) in changes {
......@@ -1402,9 +1371,15 @@ pub(crate) fn build_on(
14021371 let (rid, label, revision, replacements, new_space) = match change {
14031372 RevisionEdit::Update(objects) => {
14041373 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 )
14061381 }
1407 #[cfg(any(test, feature = "protected"))]
1382 #[cfg(test)]
14081383 RevisionEdit::Create { roots, objects } => {
14091384 if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) {
14101385 return Err(Error {
......@@ -1429,30 +1404,6 @@ pub(crate) fn build_on(
14291404 true,
14301405 )
14311406 }
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 }
14561407 };
14571408 let depth = rid.map_or(0, |rid| chain_depth(index, space, rid));
14581409 let mut live = LiveRevision::new(revision, depth)?;
......@@ -1478,25 +1429,6 @@ pub(crate) fn build_on(
14781429 })
14791430 .collect::<Result<Vec<_>>>()?,
14801431 )?;
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 };
15001432 appending.revision(
15011433 &Sealing {
15021434 space,
......@@ -1510,11 +1442,10 @@ pub(crate) fn build_on(
15101442 created: &created,
15111443 },
15121444 &[(0, source)],
1513 protection,
1514 key,
1445 None,
15151446 )?;
15161447 }
1517 appending.payloads(payloads, protection)?;
1448 appending.payloads(payloads, None)?;
15181449 Ok(appending.finish()?.map(|(transaction, _)| transaction))
15191450}
15201451
......@@ -1528,7 +1459,6 @@ pub(crate) fn applied(source: &[u8], transaction: Option<&crate::Transaction>) -
15281459}
15291460
15301461/// Payload identities the file-data store of `store` declares, in order.
1531#[cfg(any(test, feature = "protected"))]
15321462pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> {
15331463 store
15341464 .lists
......@@ -1541,13 +1471,12 @@ pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> {
15411471
15421472/// Writes the live objects of each `edited` revision that differ from its space's active
15431473/// revision in the validated `source` as one revision per space, embedding the `payloads`
1544/// it lacks. A protected `source` takes the revisions its plaintext twin gained.
1545#[cfg(any(test, feature = "protected"))]
1474/// it lacks.
1475#[cfg(test)]
15461476pub(crate) fn squash(
15471477 source: &RevisionIndex<'_>,
15481478 edited: &[(ExGuid, &crate::ResolvedRevision<'_>)],
15491479 payloads: &[([u8; 16], &[u8])],
1550 protection: Option<&dyn Protection>,
15511480) -> Result<Option<crate::Transaction>> {
15521481 let existing = declared_payloads(source.store);
15531482 let payloads: Vec<_> = payloads
......@@ -1555,13 +1484,10 @@ pub(crate) fn squash(
15551484 .filter(|(guid, _)| !existing.contains(guid))
15561485 .copied()
15571486 .collect();
1558 build_on(source, &payloads, protection, |index| {
1487 build_on(source, &payloads, |index| {
15591488 let mut changes = BTreeMap::new();
15601489 for (space, after) in edited {
1561 let before = match protection {
1562 Some(protection) => protection.resolve(*space, index.active(*space)?)?,
1563 None => index.resolve_active(*space)?,
1564 };
1490 let before = index.resolve_active(*space)?;
15651491 if before.roots != after.roots {
15661492 return Err(Error {
15671493 offset: 0,
......@@ -1590,6 +1516,111 @@ pub(crate) fn squash(
15901516 })
15911517}
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
15931624/// A space's revision as `LiveRevision::commit` left it, ready to append.
15941625pub(crate) struct Sealing<'r, 'a> {
15951626 pub space: ExGuid,
......@@ -1664,7 +1695,7 @@ impl Appending {
16641695 self.base.length + self.append.len() as u64
16651696 }
16661697
1667 fn append(&mut self, bytes: &[u8]) -> Result<Chunk> {
1698 pub(crate) fn append(&mut self, bytes: &[u8]) -> Result<Chunk> {
16681699 let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error {
16691700 offset: 0,
16701701 message: "Chunk exceeds the encoded length limit",
......@@ -1743,10 +1774,9 @@ impl Appending {
17431774 sealing: &Sealing<'_, '_>,
17441775 segments: &[(u64, &[u8])],
17451776 protection: Option<&dyn Protection>,
1746 key: Option<&crate::Node<'_>>,
17471777 ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> {
17481778 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)?;
17501780 self.close(sealing.space, sealing.new_space, &manifest)?;
17511781 Ok(written)
17521782 }
......@@ -1759,7 +1789,6 @@ impl Appending {
17591789 sealing: &Sealing<'_, '_>,
17601790 segments: &[(u64, &[u8])],
17611791 protection: Option<&dyn Protection>,
1762 key: Option<&crate::Node<'_>>,
17631792 manifest: &mut Vec<Vec<u8>>,
17641793 ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> {
17651794 let is_section = self.state.file_type == FileType::Section;
......@@ -1863,12 +1892,14 @@ impl Appending {
18631892 None,
18641893 &start,
18651894 )?);
1866 if protection.is_some() && checkpoint {
1867 let key = key.ok_or(Error {
1868 offset: 0,
1869 message: "Protected revisions continue a protected object space",
1870 })?;
1871 manifest.push(node(0x7c, key.reference, key.payload)?);
1895 // MS-ONESTORE 2.5.19 has every revision of a protected space name its key; OneNote
1896 // names it only in revisions without a dependency, and reads either.
1897 if let Some(protection) = protection {
1898 manifest.push(node(
1899 0x7c,
1900 Some(Reference::Data(protection.metadata())),
1901 &[],
1902 )?);
18721903 }
18731904 let mut stored = Vec::new();
18741905 for (table, objects) in groups {
......@@ -2084,7 +2115,9 @@ impl Appending {
20842115 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a,
20852116 0x9e, 0xac,
20862117 ];
2087 let sealed = protection.map(|protection| protection.seal_file(payload));
2118 let sealed = protection
2119 .map(|protection| protection.seal_file(payload))
2120 .transpose()?;
20882121 let payload = sealed.as_deref().unwrap_or(*payload);
20892122 blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());
20902123 blob.extend_from_slice(&[0; 12]);
......@@ -2279,6 +2312,7 @@ pub(crate) struct Written<'r, 'a> {
22792312/// object parses, names identities its group's table holds and objects reachable after its
22802313/// revision, carries its incremental reference count and, if read-only, its MD5; roots stay
22812314/// unless a space is new. Validates nothing the transaction leaves as it was.
2315#[allow(clippy::too_many_arguments)]
22822316pub(crate) fn check_transaction(
22832317 before: &StoreState,
22842318 after: &StoreState,
......@@ -2287,8 +2321,10 @@ pub(crate) fn check_transaction(
22872321 revisions: &[Written<'_, '_>],
22882322 labels: &[(ExGuid, ExGuid, ExGuid)],
22892323 payloads: &[([u8; 16], &[u8])],
2324 protection: Option<&dyn Protection>,
22902325) -> Result<()> {
22912326 let wrong = |message| Error { offset: 0, message };
2327 let encoding: [u8; 2] = if protection.is_some() { [2, 0] } else { [0, 0] };
22922328 let base = transaction.base.length;
22932329 // A chunk's bytes with this transaction's writes: nothing read here is patched earlier.
22942330 let read = |chunk: Chunk| -> Result<Vec<u8>> {
......@@ -2449,13 +2485,20 @@ pub(crate) fn check_transaction(
24492485 || c.exguid()? != rid
24502486 || c.exguid()? != dependency
24512487 || c.read::<4>()? != label.1.to_le_bytes()
2452 || c.read::<2>()? != [0, 0]
2488 || c.read::<2>()? != encoding
24532489 || (contextual && c.exguid()? != label.0)
24542490 {
24552491 return Err(wrong(
24562492 "A revision starts with the wrong identity, dependency or label",
24572493 ));
24582494 }
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 }
24592502 let mut declared = BTreeSet::new();
24602503 let mut roots = BTreeMap::new();
24612504 loop {
......@@ -2513,10 +2556,14 @@ pub(crate) fn check_transaction(
25132556 return Err(wrong("An object declaration lacks its data"));
25142557 };
25152558 let data = read(at)?;
2559 let clear = match protection {
2560 Some(protection) => protection.open_property(&data)?,
2561 None => zeroize::Zeroizing::new(data.clone()),
2562 };
25162563 let stored = crate::Object {
25172564 jcid,
25182565 reference_count: count,
2519 data: ObjectData::Properties(&data),
2566 data: ObjectData::Properties(&clear),
25202567 global_ids: Arc::new(table.clone()),
25212568 };
25222569 let references = stored.references()?;
......@@ -2525,11 +2572,14 @@ pub(crate) fn check_transaction(
25252572 !references.object_spaces.is_empty()
25262573 || !references.contexts.is_empty(),
25272574 ) << 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)
25292580 || flags != Some(expected)
25302581 || (item.id == 0xc5) != (jcid & 0x100000 != 0)
2531 || (item.id == 0xc5
2532 && c.read::<16>()? != md5::compute(&data).0)
2582 || (item.id == 0xc5 && c.read::<16>()? != hash)
25332583 {
25342584 return Err(wrong(
25352585 "An object's stored bytes differ from its revision",
......@@ -2640,8 +2690,12 @@ pub(crate) fn check_transaction(
26402690 return Err(wrong("A payload declaration lacks its data"));
26412691 };
26422692 let data = read(at)?;
2643 let blob = crate::files::payload(&data, at.offset as usize);
2644 if node.id != 0x94 || node.payload != guid || blob.ok() != Some(*payload) {
2693 let blob = crate::files::payload(&data, at.offset as usize)?;
2694 let blob = match protection {
2695 Some(protection) => protection.open_file(blob)?,
2696 None => zeroize::Zeroizing::new(blob.to_vec()),
2697 };
2698 if node.id != 0x94 || node.payload != guid || blob.as_slice() != *payload {
26452699 return Err(wrong("An embedded payload differs from its declaration"));
26462700 }
26472701 }
crates/onestore/tests/protected.rs+295-150
......@@ -1,52 +1,54 @@
1#![cfg(feature = "protected")]
2
31use onestore::{
4 RevisionIndex, Store,
2 Arena, ExGuid, RevisionIndex, Section, Store,
53 document::{Document, Kind},
6 op::{Edit, Op, PageOp},
7 protected::{Error, Limits, UnlockedSection},
4 op::{Edit, Op},
5 page::Page,
6 protected::{Error, Key, Limits, UnlockedSection, rekey},
87};
98use std::{fs, path::Path};
109
11#[test]
12fn known_passwords_open_independent_native_fixtures() {
13 for (root, notebook, pages) in [
14 ("native-encrypted", "encrypted-01/notebook/synthetic.one", 1),
15 ("native-protected-boundaries", "notebook/synthetic.one", 11),
16 ] {
10/// The native fixtures with their passwords.
11fn fixtures() -> Vec<(Vec<u8>, String)> {
12 [
13 ("native-encrypted", "encrypted-01/notebook/synthetic.one"),
14 ("native-protected-boundaries", "notebook/synthetic.one"),
15 ]
16 .into_iter()
17 .map(|(root, notebook)| {
1718 let root = Path::new("../../corpus").join(root);
1819 let manifest: serde_json::Value =
1920 serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap();
20 let password = manifest["password"].as_str().unwrap();
21 let bytes = fs::read(root.join(notebook)).unwrap();
22 let before = bytes.clone();
21 (
22 fs::read(root.join(notebook)).unwrap(),
23 manifest["password"].as_str().unwrap().to_owned(),
24 )
25 })
26 .collect()
27}
28
29/// Every page of a section `Section::open` or `Section::unlock` reads, in order.
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]) {
2346 let store = Store::parse(&bytes).unwrap();
2447 let index = RevisionIndex::parse(&store).unwrap();
2548 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();
2750 let document = unlocked.document().unwrap();
28 assert_eq!(document.pages().unwrap().len(), pages);
29 let (space, _) = document.pages().unwrap()[0];
30 let current = &document.spaces[&space];
31 let revision = &current.revisions[&current.contexts[&onestore::ExGuid::default()]];
32 let (object, _) = revision
33 .nodes
34 .iter()
35 .find(|(_, node)| matches!(node.kind, Kind::RichText { .. }))
36 .unwrap();
37 // The ordinary section writer refuses a protected section.
38 let arena = onestore::Arena::default();
39 assert!(onestore::Section::open(&arena, bytes.clone()).is_err());
40 let op = PageOp::Text {
41 text: *object,
42 range: 0..0,
43 with: "Must remain protected".into(),
44 };
45 let edit = Edit {
46 at: 134_000_000_000_000_000,
47 ops: vec![Op::Page { space, op }],
48 };
49 assert!(unlocked.apply("Rust", &edit).is_ok());
51 assert_eq!(document.pages().unwrap().len(), count);
5052 assert!(
5153 document
5254 .spaces
......@@ -55,26 +57,26 @@ fn known_passwords_open_independent_native_fixtures() {
5557 .flat_map(|r| r.nodes.values())
5658 .all(|n| !matches!(n.kind, Kind::Encrypted { .. }))
5759 );
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 }
5870 assert!(matches!(
59 UnlockedSection::open(
60 &index,
61 "deliberately incorrect fixture password",
62 Limits::default()
63 ),
71 Key::open(&bytes, "deliberately incorrect fixture password"),
6472 Err(Error::PasswordMismatch)
6573 ));
66 assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty());
67 assert_eq!(bytes, before);
6874 }
6975}
7076
7177#[test]
7278fn limits_and_password_bytes_are_explicit() {
73 let root = Path::new("../../corpus/native-protected-boundaries");
74 let manifest: serde_json::Value =
75 serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap();
76 let password = manifest["password"].as_str().unwrap();
77 let bytes = fs::read(root.join("notebook/synthetic.one")).unwrap();
79 let (bytes, password) = fixtures().remove(1);
7880 let store = Store::parse(&bytes).unwrap();
7981 let index = RevisionIndex::parse(&store).unwrap();
8082 for limits in [
......@@ -92,7 +94,7 @@ fn limits_and_password_bytes_are_explicit() {
9294 },
9395 ] {
9496 assert!(matches!(
95 UnlockedSection::open(&index, password, limits),
97 UnlockedSection::open(&index, &password, limits),
9698 Err(Error::Limit)
9799 ));
98100 }
......@@ -102,111 +104,111 @@ fn limits_and_password_bytes_are_explicit() {
102104 password.to_uppercase(),
103105 ] {
104106 assert!(matches!(
105 UnlockedSection::open(&index, &changed, Limits::default()),
107 Key::open(&bytes, &changed),
106108 Err(Error::PasswordMismatch)
107109 ));
108110 }
109111 assert!(matches!(
110 UnlockedSection::open(&index, &"x".repeat(65537), Limits::default()),
112 Key::open(&bytes, &"x".repeat(65537)),
111113 Err(Error::Limit)
112114 ));
113115 let ordinary = onestore::create_section("ordinary.one", "Fictitious", "Author").unwrap();
114 let store = Store::parse(&ordinary).unwrap();
115 let index = RevisionIndex::parse(&store).unwrap();
116116 assert!(matches!(
117 UnlockedSection::open(&index, password, Limits::default()),
117 Key::open(&ordinary, &password),
118118 Err(Error::Unsupported)
119119 ));
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 ));
120126}
121127
122/// The first text paragraph of every page gains text; the written file stays protected,
123/// opens with the same password and reads back as the edited model.
128/// Appends an attachment and text to the first text paragraph of `page`; none without one.
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.
124184#[test]
125185fn a_protected_page_edit_is_stored_under_the_section_key() {
126 use onestore::page::{Page, PageObject, Paragraph};
127 for (root, notebook) in [
128 ("native-encrypted", "encrypted-01/notebook/synthetic.one"),
129 ("native-protected-boundaries", "notebook/synthetic.one"),
130 ] {
131 let root = Path::new("../../corpus").join(root);
132 let manifest: serde_json::Value =
133 serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap();
134 let password = manifest["password"].as_str().unwrap();
135 let mut bytes = fs::read(root.join(notebook)).unwrap();
136 let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> {
137 let store = Store::parse(bytes).unwrap();
138 let index = RevisionIndex::parse(&store).unwrap();
139 assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty());
140 let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap();
141 let document = unlocked.document().unwrap();
142 document
143 .pages()
144 .unwrap()
145 .into_iter()
146 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
147 .collect()
148 };
149 let mut expected = pages(&bytes);
186 for (mut bytes, password) in fixtures() {
187 let key = Key::open(&bytes, &password).unwrap();
188 let mut expected =
189 pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap());
150190 let mut edited = 0;
151191 for (space, page) in &mut expected {
152 let before = page.clone();
153 let paragraphs = page.objects.iter_mut().find_map(|object| match object {
154 PageObject::Outline(outline)
155 if outline.paragraphs.iter().any(|p| p.text().is_some()) =>
156 {
157 Some(&mut outline.paragraphs)
158 }
159 _ => None,
160 });
161 let Some(paragraphs) = paragraphs else {
192 let Some((edit, after)) = edit(*space, page) else {
162193 continue;
163194 };
195 *page = after;
164196 edited += 1;
165 let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap();
166 let mut file = paragraphs[at].clone();
167 file.id = onestore::page::text::new_id().unwrap();
168 file.lists.clear();
169 file.tags.clear();
170 file.style = None;
171 file.format = Default::default();
172 file.content =
173 onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
174 id: onestore::page::text::new_id().unwrap(),
175 filename: "sealed.txt".into(),
176 source_path: None,
177 size: Some([24.0, 24.0]),
178 layout: Default::default(),
179 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),
180 preview: None,
181 recording: None,
182 tags: Vec::new(),
183 });
184 paragraphs.insert(at + 1, file);
185 let text = paragraphs[at].text_mut().unwrap();
186 let format = text.text.format_at(0).unwrap().clone();
187 text.text
188 .append(Paragraph::new(" still protected".to_owned(), format))
189 .unwrap();
190 let ops = onestore::op::lower_page(&before, page).unwrap();
191 let edit = Edit {
192 at: 134_000_000_000_000_000,
193 ops: ops
194 .into_iter()
195 .map(|op| Op::Page { space: *space, op })
196 .collect(),
197 };
198 let store = Store::parse(&bytes).unwrap();
199 let index = RevisionIndex::parse(&store).unwrap();
200 assert!(matches!(
201 UnlockedSection::open(&index, "wrong", Limits::default()),
202 Err(Error::PasswordMismatch)
203 ));
204 let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap();
205 let transaction = unlocked.apply("Rust", &edit).unwrap();
197 let arena = Arena::default();
198 let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap();
199 section.apply("Rust", &edit).unwrap();
200 let transaction = section.seal().unwrap().unwrap();
206201 let mut written = bytes.clone();
207202 transaction.apply(&mut written).unwrap();
203 assert_eq!(written, section.image());
208204 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 }
210212 }
211213 let revisions = |bytes: &[u8]| {
212214 let store = Store::parse(bytes).unwrap();
......@@ -224,13 +226,23 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {
224226 .map(|(before, _)| before.0)
225227 .collect();
226228 assert_eq!(grown, [*space]);
229 // The section kept open reads its own seal back.
230 assert_eq!(section.page(*space).unwrap().objects, page.objects);
227231 bytes = written;
228232 }
229233 assert!(edited > 0);
230 let stored = pages(&bytes);
231 assert_eq!(stored.len(), expected.len());
232 for ((_, stored), (_, expected)) in stored.iter().zip(&expected) {
233 assert_eq!(stored.objects, expected.objects);
234 let stored = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap());
235 assert_eq!(objects(&stored), objects(&expected));
236 // The document reader agrees, under the password alone.
237 let store = Store::parse(&bytes).unwrap();
238 let index = RevisionIndex::parse(&store).unwrap();
239 let unlocked = UnlockedSection::open(&index, &password, Limits::default()).unwrap();
240 let document = unlocked.document().unwrap();
241 for (space, page) in &expected {
242 assert_eq!(
243 Page::from_space(&document, *space).unwrap().objects,
244 page.objects
245 );
234246 }
235247 }
236248}
......@@ -239,9 +251,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {
239251#[test]
240252fn a_native_revision_inherits_the_key_of_its_dependency() {
241253 let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap();
242 let manifest: serde_json::Value =
243 serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap())
244 .unwrap();
254 let (_, password) = fixtures().remove(0);
245255 let store = Store::parse(&bytes).unwrap();
246256 let index = RevisionIndex::parse(&store).unwrap();
247257 assert!(index.spaces.values().any(|space| {
......@@ -249,14 +259,149 @@ fn a_native_revision_inherits_the_key_of_its_dependency() {
249259 revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c)
250260 })
251261 }));
252 let unlocked = UnlockedSection::open(
253 &index,
254 manifest["password"].as_str().unwrap(),
255 Limits::default(),
256 )
257 .unwrap();
258 let document = unlocked.document().unwrap();
259 let (space, _) = document.pages().unwrap()[0];
260 let page = onestore::page::Page::from_space(&document, space).unwrap();
261 assert!(format!("{:?}", page.objects).contains("Native edit after Rust."));
262 let key = Key::open(&bytes, &password).unwrap();
263 let read = pages(&mut Section::unlock(&Arena::default(), bytes, &key).unwrap());
264 assert!(format!("{:?}", read[0].1.objects).contains("Native edit after Rust."));
265}
266
267/// Setting, changing and removing a password rewrites the section under new identities,
268/// keeping every page, version and payload; each password opens only its own image.
269#[test]
270fn a_password_is_set_changed_and_removed() {
271 let plain = fs::read("../../corpus/page-versions/native/step-09/notebook/History.one").unwrap();
272 let arena = Arena::default();
273 let mut section = Section::open(&arena, plain.clone()).unwrap();
274 let before = pages(&mut section);
275 let versions = section.versions().unwrap();
276 assert!(!versions.is_empty());
277 let identity = |bytes: &[u8]| {
278 let store = Store::parse(bytes).unwrap();
279 (
280 store.header.file_id,
281 RevisionIndex::parse(&store).unwrap().root,
282 )
283 };
284
285 let first = Key::new("first password").unwrap();
286 let protected = rekey(&plain, None, Some(&first)).unwrap();
287 assert!(keyed(&protected));
288 assert_eq!(protected[128..148], plain[128..148]);
289 assert_ne!(identity(&protected).0, identity(&plain).0);
290 assert_ne!(identity(&protected).1, identity(&plain).1);
291 assert!(Section::open(&Arena::default(), protected.clone()).is_err());
292 // The encryption data OneNote writes, which the password alone opens again.
293 let reopened = Key::open(&protected, "first password").unwrap();
294 assert_eq!(reopened.secret(), first.secret());
295 assert!(matches!(
296 Key::open(&protected, "First password"),
297 Err(Error::PasswordMismatch)
298 ));
299 let arena = Arena::default();
300 let mut section = Section::unlock(&arena, protected.clone(), &reopened).unwrap();
301 assert_eq!(objects(&pages(&mut section)), objects(&before));
302 assert_eq!(
303 section
304 .versions()
305 .unwrap()
306 .iter()
307 .map(|(_, versions)| versions.len())
308 .collect::<Vec<_>>(),
309 versions
310 .iter()
311 .map(|(_, versions)| versions.len())
312 .collect::<Vec<_>>()
313 );
314 let store = Store::parse(&protected).unwrap();
315 let index = RevisionIndex::parse(&store).unwrap();
316 let unlocked = UnlockedSection::open(&index, "first password", Limits::default()).unwrap();
317 assert_eq!(
318 unlocked.document().unwrap().pages().unwrap().len(),
319 before.len()
320 );
321
322 let second = Key::new("second password").unwrap();
323 assert!(matches!(
324 rekey(&protected, None, Some(&second)),
325 Err(Error::PasswordMismatch)
326 ));
327 let changed = rekey(&protected, Some(&first), Some(&second)).unwrap();
328 assert_ne!(first.secret(), second.secret());
329 assert!(matches!(
330 Key::open(&changed, "first password"),
331 Err(Error::PasswordMismatch)
332 ));
333 assert!(matches!(
334 Section::unlock(&Arena::default(), changed.clone(), &first),
335 Err(Error::PasswordMismatch)
336 ));
337 let arena = Arena::default();
338 let mut section = Section::unlock(&arena, changed.clone(), &second).unwrap();
339 assert_eq!(objects(&pages(&mut section)), objects(&before));
340
341 let removed = rekey(&changed, Some(&second), None).unwrap();
342 assert!(
343 RevisionIndex::parse(&Store::parse(&removed).unwrap())
344 .unwrap()
345 .spaces
346 .values()
347 .flat_map(|space| space.revisions.values())
348 .all(|revision| !revision.encrypted)
349 );
350 let arena = Arena::default();
351 let mut section = Section::open(&arena, removed).unwrap();
352 assert_eq!(objects(&pages(&mut section)), objects(&before));
353 assert_eq!(section.versions().unwrap().len(), versions.len());
354}
355
356/// Payloads survive a password of their own: every native attachment boundary length.
357#[test]
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 }
262407}
crates/snowbound/Cargo.toml+2
......@@ -31,6 +31,8 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
3131# Updates: fetched over HTTPS, verified with ed25519 and SHA-256.
3232ureq = { version = "3.4", default-features = false, features = ["rustls"] }
3333ring = "0.17"
34# Clears passwords typed into the password dialogs.
35zeroize = "1.9.0"
3436
3537[target.'cfg(target_os = "macos")'.dependencies]
3638libc = "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 @@
55 <alias type="application/msonenote"/>
66 <glob pattern="*.one"/>
77 <glob pattern="*.onetoc2"/>
8 <glob pattern="*.onepkg"/>
89 </mime-type>
910</mime-info>
crates/snowbound/linux/package.sh+4-26
......@@ -1,39 +1,17 @@
11#!/bin/sh
22# 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.
44set -eu
55root=$(cd "$(dirname "$0")/../../.." && pwd)
66here="$root/crates/snowbound/linux"
77target="$root/target"
8tools="$target/zig"
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"
8mkdir -p "$target/dist"
149
1510[ $# -gt 0 ] || set -- x86_64 aarch64
1611for arch do
1712 triple=$arch-unknown-linux-gnu
18 variable=$(echo "$triple" | tr 'a-z-' 'A-Z_')
19 linker="$tools/cc-$arch"
20 # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig
21 # rejects rustc's --target, --no-undefined-version and -O1.
22 cat > "$linker" <<EOF
23#!/bin/sh
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
13 CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \
14 --manifest-path "$root/Cargo.toml" --release -p snowbound
3715
3816 name=snowbound-linux-$arch
3917 stage="$target/dist/$name"
crates/snowbound/src/aqua.rs+4-21
......@@ -17,10 +17,7 @@ use std::{
1717 sync::atomic::{AtomicBool, Ordering},
1818};
1919use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};
20use winit::{
21 raw_window_handle::{HasWindowHandle, RawWindowHandle},
22 window::{CursorIcon, Window},
23};
20use winit::window::{CursorIcon, Window};
2421
2522static PRETEND: AtomicBool = AtomicBool::new(false);
2623
......@@ -49,14 +46,8 @@ pub fn textured(window: &Window, row: f32) -> bool {
4946 return false;
5047 }
5148 TEXTURED.store(true, Ordering::Relaxed);
52 let RawWindowHandle::AppKit(handle) =
53 window.window_handle().expect("Live AppKit window").as_raw()
54 else {
55 unreachable!()
56 };
49 let window = crate::platform::ns_window(window);
5750 unsafe {
58 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
59 let window: Retained<AnyObject> = msg_send_id![view, window];
6051 let title: Retained<AnyObject> = msg_send_id![&window, title];
6152 let mask: usize = msg_send![&window, styleMask];
6253 // NSTexturedBackgroundWindowMask.
......@@ -450,16 +441,8 @@ pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) {
450441 }
451442 static HIDDEN: std::sync::Once = std::sync::Once::new();
452443 HIDDEN.call_once(|| {
453 let RawWindowHandle::AppKit(handle) =
454 window.window_handle().expect("Live AppKit window").as_raw()
455 else {
456 unreachable!()
457 };
458 unsafe {
459 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
460 let window: Retained<AnyObject> = msg_send_id![view, window];
461 let _: () = msg_send![&window, setShowsResizeIndicator: false];
462 }
444 let window = crate::platform::ns_window(window);
445 let _: () = unsafe { msg_send![&window, setShowsResizeIndicator: false] };
463446 });
464447 thread_local! {
465448 static GRIP: draw::RasterImage = {
crates/snowbound/src/art.rs+2
......@@ -24,6 +24,7 @@ pub const ICLOUD: &[&str] = art!("icons/icloud");
2424pub const FONT_COLOR: &[&str] = art!("icons/font-color");
2525pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");
2626pub const INDENT: &[&str] = art!("icons/indent");
27pub const INFO: &[&str] = art!("icons/info");
2728pub const ITALIC: &[&str] = art!("icons/italic");
2829pub const LINK: &[&str] = art!("icons/link");
2930pub const NOTEBOOK: &[&str] = art!("icons/notebook");
......@@ -46,6 +47,7 @@ pub const SUBSCRIPT: &[&str] = art!("icons/subscript");
4647pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");
4748pub const TABLE: &[&str] = art!("icons/table");
4849pub const UNDERLINE: &[&str] = art!("icons/underline");
50pub const WARNING: &[&str] = art!("icons/warning");
4951pub const UNDO: &[&str] = art!("icons/undo");
5052pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
5153pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
crates/snowbound/src/commands.rs+121-8
......@@ -26,7 +26,22 @@ pub enum Id {
2626 NewSubpage,
2727 PageVersions,
2828 CopyPageLink,
29 /// Password Protection on the section shown.
30 PasswordProtect,
31 /// Locks every password-protected section unlocked.
32 LockAll,
2933 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,
3045 ExportPdf,
3146 Print,
3247 Undo,
......@@ -238,6 +253,18 @@ pub const COMMANDS: &[Command] = &[
238253 ),
239254 row!(Id::PageVersions, "Page Versions", NONE, NONE),
240255 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 ),
241268 row!(
242269 Id::ShowNotebook,
243270 if cfg!(target_os = "macos") {
......@@ -248,6 +275,24 @@ pub const COMMANDS: &[Command] = &[
248275 NONE,
249276 NONE,
250277 ),
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),
251296 row!(Id::ExportPdf, "Export as PDF…", NONE, NONE),
252297 // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P.
253298 row!(
......@@ -1003,7 +1048,7 @@ impl State {
10031048 /// ⌘K, to the open palette's actions.
10041049 pub(crate) fn statuses(&self) -> Vec<Status> {
10051050 // 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()) {
10071052 return vec![Status::default(); choices().count()];
10081053 }
10091054 let format = self.format_state();
......@@ -1020,6 +1065,7 @@ impl State {
10201065 let session = self.session.as_ref();
10211066 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
10221067 let modal = self.options.is_some()
1068 || self.password.is_some()
10231069 || self.themes.is_some()
10241070 || self.printing.open()
10251071 || self.link.is_some()
......@@ -1071,6 +1117,39 @@ impl State {
10711117 | Id::Help
10721118 | Id::CheckForUpdates => enabled(!modal),
10731119 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),
10741153 Id::ShowNotebook => enabled(
10751154 !modal
10761155 && self
......@@ -1083,13 +1162,13 @@ impl State {
10831162 .notebook()
10841163 .is_some_and(|library| library.catalog().is_some()),
10851164 ),
1086 Id::NewPage
1087 | Id::NewSubpage
1088 | Id::CopyPageLink
1089 | Id::Find
1090 | Id::Search
1091 | Id::ExportPdf
1092 | Id::Print => enabled(!modal && session.is_some()),
1165 // The recycle bin takes no new pages.
1166 Id::NewPage | Id::NewSubpage => {
1167 enabled(!modal && session.is_some() && !self.in_recycle_bin())
1168 }
1169 Id::CopyPageLink | Id::Find | Id::Search | Id::ExportPdf | Id::Print => {
1170 enabled(!modal && session.is_some())
1171 }
10931172 Id::PageVersions => session.filter(|_| !modal).map_or(
10941173 Status {
10951174 enabled: false,
......@@ -1338,6 +1417,40 @@ impl State {
13381417 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
13391418 return Ok(());
13401419 }
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 }
13411454 Id::ExportPdf | Id::Print => {
13421455 self.open_print(id == Id::ExportPdf);
13431456 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> {
6464 &theme,
6565 &section,
6666 session,
67 &HashSet::new(),
67 [&HashSet::new(); 2],
6868 (0.0, false),
6969 None,
7070 None,
crates/snowbound/src/desktop_linux.rs+1-1
......@@ -36,7 +36,7 @@ use winit::{
3636/// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon.
3737pub const APP_ID: &str = "net.paperclover.snowbound";
3838const 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.
4040const MIME_TYPES: &str = include_str!("../linux/onenote.xml");
4141/// 512 pixels square; the smaller sizes are scaled from it.
4242const ICON: &[u8] = include_bytes!("../linux/snowbound.png");
crates/snowbound/src/history.rs+34-52
......@@ -23,9 +23,12 @@ impl Session {
2323 .map_or(&[], |(_, versions)| versions)
2424 }
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.
2728 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)
2932 }
3033
3134 /// Version `version` of page `space` as the editor shows it: read-only, what it changed
......@@ -166,6 +169,7 @@ impl State {
166169 .section
167170 .restore_version(page, version, &self.author)?;
168171 session.refresh_history()?;
172 self.edited(vec![page]);
169173 self.commands.push(Command::OpenPage(page));
170174 Ok(())
171175 }
......@@ -207,19 +211,7 @@ impl State {
207211 ) {
208212 return Ok(());
209213 }
210 let all: Vec<(ExGuid, Vec<ExGuid>)> = session
211 .history
212 .iter()
213 .map(|(page, versions)| {
214 (
215 *page,
216 versions.iter().map(|version| version.context).collect(),
217 )
218 })
219 .collect();
220 if !all.is_empty() {
221 session.section.delete_versions(&all)?;
222 }
214 clear(&session.section)?;
223215 session.refresh_history()?;
224216 if session.version.is_some() {
225217 self.commands.push(Command::OpenPage(session.space));
......@@ -242,22 +234,7 @@ impl State {
242234 let cleared = library
243235 .open(&path, notify(proxy.clone()))
244236 .and_then(|section| {
245 let all: Vec<(ExGuid, Vec<ExGuid>)> = section
246 .versions()?
247 .into_iter()
248 .map(|(page, versions)| {
249 (
250 page,
251 versions
252 .into_iter()
253 .map(|version| version.context)
254 .collect(),
255 )
256 })
257 .collect();
258 if !all.is_empty() {
259 section.delete_versions(&all)?;
260 }
237 clear(&section)?;
261238 Ok(section.close()?)
262239 });
263240 if let Err(error) = cleared {
......@@ -269,31 +246,36 @@ impl State {
269246 }
270247}
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
272265/// The readable sections at catalog path `folder` and in the groups inside it, leaving out
273266/// the recycle bin.
274267fn sections(library: &Library, folder: &str) -> Vec<String> {
275268 let Some(catalog) = library.catalog() else {
276269 return vec![library.location.clone()];
277270 };
278 let mut pending = vec![catalog];
279 let mut root = None;
280 while let Some(candidate) = pending.pop() {
281 if candidate.path == folder {
282 root = Some(candidate);
283 break;
284 }
285 pending.extend(&candidate.groups);
286 }
287 let mut pending: Vec<_> = root.into_iter().collect();
288 let mut paths = Vec::new();
289 while let Some(folder) = pending.pop() {
290 paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path));
291 pending.extend(
292 folder
293 .groups
294 .iter()
295 .filter(|group| !library::recycle_bin(&group.path)),
296 );
297 }
298 paths
271 let root = library::folders(catalog, |_| true)
272 .into_iter()
273 .find(|candidate| candidate.path == folder);
274 root.map_or_else(Vec::new, |root| {
275 library::folders(root, |group| !library::recycle_bin(&group.path))
276 })
277 .into_iter()
278 .flat_map(|folder| library.tabs(&folder.path))
279 .map(|tab| tab.path)
280 .collect()
299281}
crates/snowbound/src/icloud_linux.rs+5-1
......@@ -25,9 +25,13 @@ pub fn section(
2525 notebook: &Notebook,
2626 _: &Path,
2727 path: &str,
28 key: Option<&onestore::protected::Key>,
2829 notify: impl Fn() + Send + 'static,
2930) -> 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 }
3135}
3236
3337pub fn lone_section(
crates/snowbound/src/icloud_macos.rs+7-2
......@@ -363,12 +363,17 @@ pub fn section(
363363 notebook: &Notebook,
364364 root: &Path,
365365 path: &str,
366 key: Option<&onestore::protected::Key>,
366367 notify: impl Fn() + Send + 'static,
367368) -> Result<Section, notebook::Error> {
368369 if !notebook.replica_path(path)?.exists() {
369370 fetch(&root.join(path))?;
370371 }
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 };
372377 section.set_pause(PAUSE);
373378 Ok(section)
374379}
......@@ -576,7 +581,7 @@ mod tests {
576581 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
577582 assert!(ubiquitous(&root));
578583 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();
580585 let (space, ..) = section.pages().unwrap()[0].clone();
581586 let title = section
582587 .page(space)
crates/snowbound/src/keys.rs+1-8
......@@ -78,7 +78,7 @@ fn label(chord: Chord) -> String {
7878fn title(id: Command) -> String {
7979 match id {
8080 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(),
8282 }
8383}
8484
......@@ -430,13 +430,6 @@ impl crate::State {
430430 taken
431431 }
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
440433 /// Puts `keymap` in use, and in the menu bar where it changes.
441434 pub(crate) fn install_keymap(&self, keymap: Keymap) {
442435 if keymap.install() {
crates/snowbound/src/library.rs+214-136
......@@ -5,6 +5,7 @@ use notebook::discover::{Folder, SectionState};
55use notebook::session::{Background, Notebook, Section};
66use notebook::sidecar::themes::Themes;
77use notebook::smb::{Client, Credentials};
8use onestore::protected::Key;
89use std::{
910 error::Error,
1011 io,
......@@ -151,14 +152,14 @@ impl Mount {
151152 let mut url = String::from("smb://");
152153 if let Some(user) = &self.user {
153154 if !self.domain.is_empty() {
154 url += &format!("{};", encode(&self.domain));
155 url += &format!("{};", encode(self.domain.as_bytes()));
155156 }
156 url += &format!("{}@", encode(user));
157 url += &format!("{}@", encode(user.as_bytes()));
157158 }
158159 url += &self.server;
159160 for part in [&self.share, &self.root] {
160161 if !part.is_empty() {
161 url += &format!("/{}", encode(part));
162 url += &format!("/{}", encode(part.as_bytes()));
162163 }
163164 }
164165 url
......@@ -232,10 +233,11 @@ fn decode(text: &str) -> String {
232233 String::from_utf8_lossy(&decoded).into_owned()
233234}
234235
235/// `text` escaped for an address, `/` kept.
236fn encode(text: &str) -> String {
237 text.bytes()
238 .map(|byte| match byte {
236/// `bytes` escaped for an address, outside RFC 3986's unreserved set and `/`.
237pub(crate) fn encode(bytes: &[u8]) -> String {
238 bytes
239 .iter()
240 .map(|&byte| match byte {
239241 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
240242 char::from(byte).to_string()
241243 }
......@@ -318,9 +320,34 @@ pub struct Library {
318320 kept: Mutex<crate::prefetch::Recent<String, Section>>,
319321 /// The notebook's style themes, and when they were read.
320322 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>,
321325}
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
323332impl 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
324351 /// The notebook in the folder at `location`, or why it cannot be read. A folder on a
325352 /// mounted SMB share opens through Snowbound's own SMB client, signed in with the
326353 /// account the system keeps for the mount, as OneNote's own client coordinates with
......@@ -333,18 +360,12 @@ impl Library {
333360 };
334361 return login
335362 .and_then(|login| Self::on_share(location, mount.clone(), login, cache))
336 .unwrap_or_else(|reason| Self {
337 location: location.to_owned(),
338 name: display_name(cache, location).unwrap_or_else(|| mount.name()),
339 notebook: Err(reason),
340 server: None,
341 notice: None,
342 cache: cache.to_owned(),
343 background: None,
344 watch: None,
345 tag_art: Default::default(),
346 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
347 themes: Default::default(),
363 .unwrap_or_else(|reason| {
364 let name = display_name(cache, location).unwrap_or_else(|| mount.name());
365 Self {
366 notebook: Err(reason),
367 ..Self::new(location, name, cache)
368 }
348369 });
349370 }
350371 let mut notice = None;
......@@ -364,20 +385,16 @@ impl Library {
364385 Ok(notebook) => local_background(notebook, location),
365386 Err(_) => (None, None),
366387 };
388 let name = display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location)));
367389 Self {
368 location: location.to_owned(),
369 name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))),
370390 background,
371391 watch,
372392 tag_art: Mutex::new(Arc::new(
373393 notebook.as_ref().map(read_tag_art).unwrap_or_default(),
374394 )),
375 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
376 themes: Default::default(),
377395 notebook: notebook.map(Some).map_err(|error| error.to_string()),
378 server: None,
379396 notice,
380 cache: cache.to_owned(),
397 ..Self::new(location, name, cache)
381398 }
382399 }
383400
......@@ -405,21 +422,17 @@ impl Library {
405422 .map_err(|error| error.to_string())?;
406423 background.set_offline(offline());
407424 background.watch(notebook.replicas());
408 Ok(Self {
409 location: location.to_owned(),
410 name: display_name(cache, location).unwrap_or_else(|| match server_address(location) {
425 let name =
426 display_name(cache, location).unwrap_or_else(|| match server_address(location) {
411427 Some(_) => server.mount.name(),
412428 None => file_name(Path::new(location)),
413 }),
429 });
430 Ok(Self {
414431 tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))),
415 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
416 themes: Default::default(),
417432 notebook: Ok(Some(notebook)),
418433 server: Some(server),
419 notice: None,
420 cache: cache.to_owned(),
421434 background: Some(Arc::new(background)),
422 watch: None,
435 ..Self::new(location, name, cache)
423436 })
424437 }
425438
......@@ -576,18 +589,16 @@ impl Library {
576589 }
577590 _ => self.watch.clone(),
578591 };
592 let name = display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone());
579593 Self {
580 location: self.location.clone(),
581 name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()),
582594 notebook: Ok(Some(notebook)),
583595 server: self.server.clone(),
584596 notice: self.notice.clone(),
585 cache: self.cache.clone(),
586597 background: self.background.clone(),
587598 watch,
588599 tag_art: Mutex::new(self.tag_art()),
589 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
590 themes: Default::default(),
600 keys: Arc::clone(&self.keys),
601 ..Self::new(&self.location, name, &self.cache)
591602 }
592603 }
593604
......@@ -595,35 +606,20 @@ impl Library {
595606 pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self {
596607 let (background, watch) = local_background(&mut notebook, location);
597608 Self {
598 location: location.to_owned(),
599 name: file_name(Path::new(location)),
600609 background,
601610 watch,
602 tag_art: Default::default(),
603 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
604 themes: Default::default(),
605611 notebook: Ok(Some(notebook)),
606 server: None,
607 notice: None,
608 cache: cache.to_owned(),
612 ..Self::new(location, file_name(Path::new(location)), cache)
609613 }
610614 }
611615
612616 /// The section file at `file` as a notebook of one tab.
613617 pub fn section(file: &Path, cache: &Path) -> Self {
614 Self {
615 location: file.to_string_lossy().into_owned(),
616 name: file_name(file.parent().unwrap_or(file)),
617 notebook: Ok(None),
618 server: None,
619 notice: None,
620 cache: cache.to_owned(),
621 background: None,
622 watch: None,
623 tag_art: Default::default(),
624 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
625 themes: Default::default(),
626 }
618 Self::new(
619 &file.to_string_lossy(),
620 file_name(file.parent().unwrap_or(file)),
621 cache,
622 )
627623 }
628624
629625 /// Opens the section at catalog `path`; on a share, through a replica named by its
......@@ -654,6 +650,11 @@ impl Library {
654650 }
655651 }
656652 };
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 }
657658 let section = loop {
658659 if let Some(kept) = self
659660 .kept
......@@ -671,7 +672,7 @@ impl Library {
671672 };
672673 let cache = notebook.replica_path(path)?;
673674 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(), || {
675676 Ok(server.connect()?.read_storage(&file, LIMIT)?)
676677 });
677678 replica.and_then(|replica| {
......@@ -681,9 +682,13 @@ impl Library {
681682 })
682683 }
683684 (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())
685687 }
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 },
687692 (Ok(None), _) if self.in_icloud() => {
688693 crate::icloud::lone_section(Path::new(path), &self.cache, notifier())
689694 }
......@@ -835,16 +840,10 @@ impl Library {
835840 /// The readable sections of the folder at catalog path `folder`, as tabs in its order.
836841 pub fn tabs(&self, folder: &str) -> Vec<Tab> {
837842 match &self.notebook {
838 Ok(Some(notebook)) => {
839 let mut folders = vec![notebook.catalog()];
840 while let Some(candidate) = folders.pop() {
841 if candidate.path == folder {
842 return tabs(candidate);
843 }
844 folders.extend(&candidate.groups);
845 }
846 Vec::new()
847 }
843 Ok(Some(notebook)) => folders(notebook.catalog(), |_| true)
844 .into_iter()
845 .find(|candidate| candidate.path == folder)
846 .map_or_else(Vec::new, tabs),
848847 Ok(None) => vec![Tab {
849848 path: self.location.clone(),
850849 name: section_name(&self.location, &None),
......@@ -856,38 +855,23 @@ impl Library {
856855
857856 /// The file identity of the section at catalog `path`.
858857 pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> {
859 let Ok(Some(notebook)) = &self.notebook else {
860 return None;
861 };
862 let mut folders = vec![notebook.catalog()];
863 while let Some(folder) = folders.pop() {
864 if let Some(section) = folder.sections.iter().find(|section| section.path == path) {
865 return Some(section.file_id);
866 }
867 folders.extend(&folder.groups);
868 }
869 None
858 folders(self.catalog()?, |_| true)
859 .into_iter()
860 .flat_map(|folder| &folder.sections)
861 .find(|section| section.path == path)
862 .map(|section| section.file_id)
870863 }
871864
872865 /// The first readable section, searching groups after sections, as OneNote opens a
873866 /// notebook.
874867 pub fn first_section(&self) -> Option<String> {
875 let Ok(Some(notebook)) = &self.notebook else {
868 let Some(catalog) = self.catalog() else {
876869 return self.tabs("").pop().map(|tab| tab.path);
877870 };
878 let mut folders = std::collections::VecDeque::from([notebook.catalog()]);
879 while let Some(folder) = folders.pop_front() {
880 if let Some(tab) = tabs(folder).into_iter().next() {
881 return Some(tab.path);
882 }
883 folders.extend(
884 folder
885 .groups
886 .iter()
887 .filter(|group| !recycle_bin(&group.path)),
888 );
889 }
890 None
871 folders(catalog, |group| !recycle_bin(&group.path))
872 .into_iter()
873 .find_map(|folder| tabs(folder).into_iter().next())
874 .map(|tab| tab.path)
891875 }
892876
893877 /// Whether the notebook lists a section at catalog path `path`.
......@@ -895,14 +879,9 @@ impl Library {
895879 let Some(catalog) = self.catalog() else {
896880 return self.location == path;
897881 };
898 let mut folders = vec![catalog];
899 while let Some(folder) = folders.pop() {
900 if folder.sections.iter().any(|section| section.path == path) {
901 return true;
902 }
903 folders.extend(&folder.groups);
904 }
905 false
882 folders(catalog, |_| true)
883 .iter()
884 .any(|folder| folder.sections.iter().any(|section| section.path == path))
906885 }
907886
908887 /// The notebook's colour, COLORREF, as its table of contents holds it.
......@@ -913,21 +892,12 @@ impl Library {
913892 /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps
914893 /// them elsewhere.
915894 pub fn downloading(&self) -> bool {
916 let Some(catalog) = self.catalog() else {
917 return false;
918 };
919 let mut folders = vec![catalog];
920 while let Some(folder) = folders.pop() {
921 if folder
922 .unavailable
923 .iter()
924 .any(|entry| entry.reason == notebook::discover::Reason::Evicted)
925 {
926 return true;
927 }
928 folders.extend(&folder.groups);
929 }
930 false
895 self.catalog().is_some_and(|catalog| {
896 folders(catalog, |_| true).iter().any(|folder| {
897 (folder.unavailable.iter())
898 .any(|entry| entry.reason == notebook::discover::Reason::Evicted)
899 })
900 })
931901 }
932902
933903 /// The notebook's folders of sections, for the sidebar.
......@@ -937,6 +907,89 @@ impl Library {
937907 _ => None,
938908 }
939909 }
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 }
940993}
941994
942995/// 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> {
9481001 .flatten()
9491002}
9501003
951/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.
9521004/// `Library::key` for the notebook at `location`, open or not.
9531005pub fn key(location: &str, path: &str) -> String {
9541006 format!("{location}\n{path}")
......@@ -971,13 +1023,28 @@ fn display_name(cache: &Path, location: &str) -> Option<String> {
9711023 read_display_names(&display_names(cache)).remove(location)
9721024}
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.
9741044pub fn recycle_bin(path: &str) -> bool {
9751045 path.rsplit('/').next() == Some("OneNote_RecycleBin")
9761046}
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.
9811048/// The art `notebook` maps its tags to, each picture read once a run.
9821049fn read_tag_art(notebook: &Notebook) -> TagArt {
9831050 let mut read = TagArt::default();
......@@ -999,6 +1066,9 @@ fn read_tag_art(notebook: &Notebook) -> TagArt {
9991066 read
10001067}
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.
10021072fn local_background(
10031073 notebook: &mut Notebook,
10041074 location: &str,
......@@ -1061,6 +1131,10 @@ pub fn file_name(path: &Path) -> String {
10611131/// A section's name: its display name, or its file's.
10621132pub fn section_name(path: &str, name: &Option<String>) -> String {
10631133 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 }
10641138 Path::new(path)
10651139 .file_stem()
10661140 .map(|stem| stem.to_string_lossy().into_owned())
......@@ -1079,6 +1153,12 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
10791153 path: section.path.clone(),
10801154 color: *color,
10811155 }),
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 }),
10821162 _ => None,
10831163 })
10841164 .collect()
......@@ -1094,6 +1174,8 @@ pub enum Located {
10941174 },
10951175 /// A section file outside any notebook.
10961176 Section(PathBuf),
1177 /// A OneNote package, which unpacks into a notebook.
1178 Package(PathBuf),
10971179 Nothing,
10981180}
10991181
......@@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool {
11111193
11121194/// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a
11131195/// 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.
11151197pub fn locate(path: &Path) -> Located {
11161198 let extension = path
11171199 .extension()
......@@ -1121,6 +1203,7 @@ pub fn locate(path: &Path) -> Located {
11211203 root: path.to_owned(),
11221204 section: None,
11231205 },
1206 Some("onepkg") => Located::Package(path.to_owned()),
11241207 Some("onetoc2") => match path.parent() {
11251208 Some(root) => Located::Notebook {
11261209 root: root.to_owned(),
......@@ -1327,9 +1410,6 @@ mod tests {
13271410 #[test]
13281411 fn share_relative_sections_show_under_their_mount() {
13291412 let library = |root: &str| Library {
1330 location: "/Volumes/agent/lab".into(),
1331 name: "lab".into(),
1332 notebook: Ok(None),
13331413 server: Some(Arc::new(Server {
13341414 mount: Mount {
13351415 server: "nas".into(),
......@@ -1344,13 +1424,7 @@ mod tests {
13441424 domain: String::new(),
13451425 },
13461426 })),
1347 notice: None,
1348 cache: PathBuf::new(),
1349 background: None,
1350 watch: None,
1351 tag_art: Default::default(),
1352 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
1353 themes: Default::default(),
1427 ..Library::new("/Volumes/agent/lab", "lab".into(), Path::new(""))
13541428 };
13551429 let shown = |root, file| library(root).local(Path::new(file));
13561430 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));
......@@ -1447,6 +1521,10 @@ mod tests {
14471521 locate(&root.join("Loose.one")),
14481522 Located::Section(root.join("Loose.one"))
14491523 );
1524 assert_eq!(
1525 locate(&root.join("Shared.onepkg")),
1526 Located::Package(root.join("Shared.onepkg"))
1527 );
14501528 assert_eq!(locate(&root.join("missing.txt")), Located::Nothing);
14511529 std::fs::remove_dir_all(&root).unwrap();
14521530 }
crates/snowbound/src/link.rs+1-16
......@@ -226,22 +226,7 @@ impl State {
226226 }
227227 }
228228 ui.close();
229 ui.open(
230 "buttons",
231 Spec {
232 size: [fill(), children()],
233 pad: [0.0, 8.0],
234 gap: 8.0,
235 ..Spec::default()
236 },
237 );
238 ui.leaf(
239 "space",
240 Spec {
241 size: [fill(), px(1.0)],
242 ..Spec::default()
243 },
244 );
229 crate::buttons(ui);
245230 let ok = ui::button(ui, "ok", "OK").clicked || entered;
246231 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
247232 ui.close();
crates/snowbound/src/linux.rs+39-43
......@@ -72,12 +72,12 @@ pub fn corner_radius(window: &Window) -> f32 {
7272 (units * scale).round() / scale
7373}
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.
7875/// Winit's theme already holds the window to the app's appearance.
7976pub 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.
8181pub fn cuts_corners() -> bool {
8282 sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed)
8383}
......@@ -617,6 +617,11 @@ pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option<ResizeDire
617617 (!window.is_maximized() && window.is_resizable()).then_some(direction)
618618}
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
620625pub fn zoom(window: &Window) {
621626 window.set_maximized(!window.is_maximized());
622627}
......@@ -773,18 +778,19 @@ pub fn show_file(file: &std::path::Path) {
773778
774779/// The `file://` URI of absolute `path`, its bytes outside RFC 3986's unreserved set and `/`
775780/// percent-encoded.
776fn file_uri(path: &std::path::Path) -> String {
781pub(crate) fn file_uri(path: &std::path::Path) -> String {
777782 use std::os::unix::ffi::OsStrExt;
778 let mut uri = "file://".to_owned();
779 for &byte in path.as_os_str().as_bytes() {
780 match byte {
781 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
782 uri.push(byte as char)
783 }
784 _ => uri.push_str(&format!("%{byte:02X}")),
785 }
786 }
787 uri
783 format!(
784 "file://{}",
785 crate::library::encode(path.as_os_str().as_bytes())
786 )
787}
788
789/// The function `name` in `library`, of C signature `F`.
790pub(crate) unsafe fn symbol<F: Copy>(library: *mut libc::c_void, name: &CStr) -> Option<F> {
791 let symbol = unsafe { libc::dlsym(library, name.as_ptr()) };
792 (!symbol.is_null())
793 .then(|| unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) })
788794}
789795
790796pub fn cache_dir() -> Option<PathBuf> {
......@@ -1124,6 +1130,17 @@ fn dialog<const Z: usize, const K: usize>(
11241130 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
11251131}
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
11271144/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
11281145pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
11291146 let patterns = match types {
......@@ -1134,21 +1151,14 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
11341151 .collect::<Vec<_>>()
11351152 .join(" "),
11361153 };
1137 let asked = dialog(
1154 picked(dialog(
11381155 [
11391156 "--file-selection",
11401157 &format!("--title={title}"),
11411158 &format!("--file-filter={patterns}"),
11421159 ],
11431160 ["--getopenfilename", ".", &patterns, "--title", title],
1144 );
1145 asked
1146 .unwrap_or_else(|error| {
1147 eprintln!("{error}");
1148 None
1149 })
1150 .filter(|path| !path.is_empty())
1151 .map(PathBuf::from)
1161 ))
11521162}
11531163
11541164/// 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
12101220/// Asks for a notebook's table of contents or a section file, titled `title`; None when
12111221/// cancelled or when no tool can ask.
12121222pub fn pick_notebook(title: &str) -> Option<PathBuf> {
1213 let asked = dialog(
1223 picked(dialog(
12141224 [
12151225 "--file-selection",
12161226 &format!("--title={title}"),
1217 "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one",
1227 "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg",
12181228 ],
12191229 [
12201230 "--getopenfilename",
12211231 ".",
1222 "*.onetoc2 *.one",
1232 "*.onetoc2 *.one *.onepkg",
12231233 "--title",
12241234 title,
12251235 ],
1226 );
1227 asked
1228 .unwrap_or_else(|error| {
1229 eprintln!("{error}");
1230 None
1231 })
1232 .filter(|path| !path.is_empty())
1233 .map(PathBuf::from)
1236 ))
12341237}
12351238
12361239/// Asks where to put something named `name` by default; the dialog names its own button.
......@@ -1240,7 +1243,7 @@ pub fn pick_new(
12401243 _action: &str,
12411244 _folder: Option<&std::path::Path>,
12421245) -> Option<PathBuf> {
1243 let asked = dialog(
1246 picked(dialog(
12441247 [
12451248 "--file-selection",
12461249 "--save",
......@@ -1248,14 +1251,7 @@ pub fn pick_new(
12481251 &format!("--filename={name}"),
12491252 ],
12501253 ["--getsavefilename", name, "--title", title],
1251 );
1252 asked
1253 .unwrap_or_else(|error| {
1254 eprintln!("{error}");
1255 None
1256 })
1257 .filter(|path| !path.is_empty())
1258 .map(PathBuf::from)
1254 ))
12591255}
12601256
12611257/// 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 {
3737
3838pub 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();
4141static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
4242static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
4343static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
......@@ -71,23 +71,25 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran
7171 } else {
7272 msg_send_id![text, copy]
7373 };
74 if let Some(proxy) = QUIT.get() {
74 if let Some(proxy) = PROXY.get() {
7575 let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string()));
7676 }
7777 }
7878 }
7979}
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> {
8283 let RawWindowHandle::AppKit(handle) =
8384 window.window_handle().expect("Live AppKit window").as_raw()
8485 else {
8586 unreachable!()
8687 };
87 unsafe {
88 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
89 msg_send_id![view, window]
90 }
88 unsafe { Retained::retain(handle.ns_view.as_ptr().cast()) }.expect("A live window has a view")
89}
90
91pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> {
92 unsafe { msg_send_id![&ns_view(window), window] }
9193}
9294
9395/// Room the traffic lights take at the title bar's leading edge.
......@@ -107,11 +109,11 @@ pub fn corner_radius(_: &Window) -> f32 {
107109 }
108110}
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.
112112/// Winit's theme already holds the window to the app's appearance.
113113pub 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.
115117pub fn cuts_corners() -> bool {
116118 crate::aqua::before_lion()
117119}
......@@ -332,13 +334,8 @@ pub fn install_backdrop(window: &Window) -> bool {
332334 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
333335 return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW);
334336 };
335 let RawWindowHandle::AppKit(handle) =
336 window.window_handle().expect("Live AppKit window").as_raw()
337 else {
338 unreachable!()
339 };
337 let view = &*ns_view(window);
340338 unsafe {
341 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
342339 let class = BACKDROP_CLASS.get_or_init(|| {
343340 let mut class =
344341 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");
......@@ -881,6 +878,11 @@ pub fn appearance(window: &Window) -> winit::window::Theme {
881878 window.theme().unwrap_or(winit::window::Theme::Dark)
882879}
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
884886/// Zooms the window once the current event is handled: AppKit's zoom animation runs its
885887/// own loop, and started from inside winit's handler it would hold every resize until the
886888/// end, stretching the last frame instead of drawing each step.
......@@ -899,13 +901,8 @@ pub fn zoom(window: &Window) {
899901/// Install before AccessKit subclasses the same view, preserving its restoration chain.
900902pub fn install_text_input(window: &Window) {
901903 MainThreadMarker::new().expect("Text input belongs to the main thread");
902 let RawWindowHandle::AppKit(handle) =
903 window.window_handle().expect("Live AppKit window").as_raw()
904 else {
905 unreachable!()
906 };
904 let view = &*ns_view(window);
907905 unsafe {
908 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
909906 let class = INPUT_CLASS.get_or_init(|| {
910907 let mut class = ClassBuilder::new("SnowboundTextInputView", view.class())
911908 .expect("Unique text input class");
......@@ -965,9 +962,7 @@ pub fn edit_date(
965962 }
966963 picker.setCalendar(Some(&calendar));
967964 picker.setTimeZone(Some(&calendar.timeZone()));
968 picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970(
969 (timestamp / 10_000_000) as f64 - 11_644_473_600.0,
970 ));
965 picker.setDateValue(&ns_date(timestamp));
971966 picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970(
972967 -11_644_473_600.0,
973968 )));
......@@ -996,9 +991,7 @@ fn merge_date(
996991 calendar: &NSCalendar,
997992) -> Result<(u64, [String; 2]), &'static str> {
998993 unsafe {
999 let original = NSDate::dateWithTimeIntervalSince1970(
1000 (timestamp / 10_000_000) as f64 - 11_644_473_600.0,
1001 );
994 let original = ns_date(timestamp);
1002995 let units = NSCalendarUnit::Era
1003996 | NSCalendarUnit::Year
1004997 | NSCalendarUnit::Month
......@@ -1053,11 +1046,14 @@ fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] {
10531046
10541047/// FILETIME as a new page's title shows it: the long date and the short time.
10551048pub 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> {
10561055 unsafe {
1057 let date = NSDate::dateWithTimeIntervalSince1970(
1058 (filetime / 10_000_000) as f64 - 11_644_473_600.0,
1059 );
1060 labels(&date, &NSCalendar::currentCalendar())
1056 NSDate::dateWithTimeIntervalSince1970((filetime / 10_000_000) as f64 - 11_644_473_600.0)
10611057 }
10621058}
10631059
......@@ -1069,9 +1065,7 @@ pub fn user_name() -> String {
10691065/// FILETIME as the system's short date, as OneNote labels a conflict page.
10701066pub fn short_date(filetime: u64) -> String {
10711067 unsafe {
1072 let date = NSDate::dateWithTimeIntervalSince1970(
1073 (filetime / 10_000_000) as f64 - 11_644_473_600.0,
1074 );
1068 let date = ns_date(filetime);
10751069 let formatter = NSDateFormatter::new();
10761070 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);
10771071 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);
......@@ -1231,13 +1225,13 @@ declare_class!(
12311225
12321226 #[method(terminate:)]
12331227 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); }
12351229 }
12361230
12371231 #[method(choose:)]
12381232 fn choose(&self, sender: &NSMenuItem) {
12391233 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))) {
12411235 let _ = proxy.send_event(crate::UserEvent::Choose(choice));
12421236 }
12431237 }
......@@ -1302,7 +1296,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
13021296 .with_activate_ignoring_other_apps(false);
13031297 }
13041298 let event_loop = builder.build()?;
1305 QUIT.set(event_loop.create_proxy())
1299 PROXY
1300 .set(event_loop.create_proxy())
13061301 .expect("Only one application event loop is created");
13071302 // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for
13081303 // 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(
13281323 app: &NSApplication,
13291324 files: &objc2_foundation::NSArray<NSString>,
13301325) {
1331 if let Some(proxy) = QUIT.get() {
1326 if let Some(proxy) = PROXY.get() {
13321327 let paths = files.iter().map(|file| file.to_string().into()).collect();
13331328 let _ = proxy.send_event(crate::UserEvent::Open(paths));
13341329 }
......@@ -1392,14 +1387,9 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool
13921387/// End AppKit preedit after a canvas action commits or cancels the composition.
13931388pub fn clear_marked_text(window: &Window) {
13941389 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 };
14001390 // Winit owns this view and implements the NSTextInputClient selectors.
1391 let view = &*ns_view(window);
14011392 unsafe {
1402 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
14031393 let marked: bool = msg_send![view, hasMarkedText];
14041394 if marked {
14051395 let _: () = msg_send![view, unmarkText];
crates/snowbound/src/main.rs+326-231
......@@ -40,13 +40,16 @@ mod paste;
4040mod platform;
4141mod prefetch;
4242mod print;
43mod properties;
4443#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
4544#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
4645#[cfg_attr(windows, path = "print_windows.rs")]
4746mod printer;
47mod properties;
48mod protection;
4849mod recording;
50mod recycle;
4951mod rename;
52mod save_as;
5053mod screenshot;
5154mod search;
5255mod server;
......@@ -64,6 +67,8 @@ mod tags;
6467mod templates;
6568mod themes;
6669mod undo;
70mod unpack;
71mod unread;
6772mod update;
6873mod video;
6974mod watch;
......@@ -106,6 +111,12 @@ use winit::{
106111const TITLE: f32 = 38.0;
107112/// Space between toolbar groups.
108113const 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;
109120const TAB_ROW: f32 = 28.0;
110121/// Width of the section colour around the page, and the rows' margin at the window's sides.
111122const FRAME: f32 = 6.0;
......@@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0;
115126const MIN_SIZE: [f32; 2] = [600.0, 400.0];
116127/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
117128const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];
118/// Font sizes the size box offers, OneNote's list in points.
119129/// Fonts the font box offers first, OneNote's default leading.
120130const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
121131/// Picks the font box and the list galleries offer as recent, at most, as OneNote's
......@@ -579,6 +589,8 @@ enum Command {
579589 },
580590 /// Deletes pages of the open section to the notebook's recycle bin.
581591 DeletePages(Vec<ExGuid>),
592 /// Restores or deletes for good pages of the recycle bin's open section.
593 Recycle(recycle::Request),
582594 /// Moves or indents pages of the open section.
583595 Pages(Vec<onestore::PageEdit>),
584596 /// Moves a page of the open section to the end of another section of its folder.
......@@ -672,7 +684,7 @@ struct State {
672684 cache: PathBuf,
673685 /// Whether the notebook sidebar is expanded.
674686 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`.
676688 folded: HashSet<String>,
677689 /// The open context menu: what it was opened on, and where.
678690 menu: Option<(menus::Target, [f32; 2])>,
......@@ -693,6 +705,12 @@ struct State {
693705 /// The Link dialog's fields while it is open.
694706 link: Option<link::LinkDialog>,
695707 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,
696714 /// Open Notebook from Server while it is open.
697715 server: Option<server::Connect>,
698716 /// New iCloud Notebook while it is open.
......@@ -728,6 +746,12 @@ struct State {
728746 /// The notebook shown while it has no section open, as one whose last section was
729747 /// deleted.
730748 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,
731755 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
732756 drag: Option<menus::Drag>,
733757 /// Pages whose template strip was dismissed this run.
......@@ -879,6 +903,7 @@ impl State {
879903 let mut notebooks = Vec::new();
880904 let mut session = None;
881905 let mut sectionless = None;
906 let mut locked = None;
882907 let (editor, scene) = match input {
883908 Input::Notes {
884909 document,
......@@ -933,7 +958,7 @@ impl State {
933958 .chain(&notebooks)
934959 .find_map(|library| Some((library, library.first_section()?)));
935960 match shown {
936 Some((library, path)) => {
961 Some((library, path)) if !library.locked(&path) => {
937962 let section = library.open(&path, notify(proxy.clone()))?;
938963 let (opened, page) =
939964 read_session(section, Arc::clone(library), path, None)?;
......@@ -941,11 +966,17 @@ impl State {
941966 session = Some(opened);
942967 (editor, Some((scene, [0.0; 2])))
943968 }
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 });
945975 sectionless = notebooks
946976 .iter()
947977 .find(|library| Some(&library.location) == current.as_ref())
948978 .or(notebooks.first())
979 .filter(|_| locked.is_none())
949980 .cloned();
950981 let blank = TextDocument::new(vec![Paragraph::new(
951982 String::new(),
......@@ -1014,6 +1045,7 @@ impl State {
10141045 .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone()));
10151046 let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone());
10161047 let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone());
1048 let reads = unread::Reads::load(&cache);
10171049 let mut state = Self {
10181050 author: stored.user_name.unwrap_or_else(platform::user_name),
10191051 window,
......@@ -1045,6 +1077,9 @@ impl State {
10451077 after_open: None,
10461078 link: None,
10471079 properties: None,
1080 save_as: None,
1081 unpacking: None,
1082 reads,
10481083 server: None,
10491084 new_icloud: None,
10501085 icloud_reading: HashSet::new(),
......@@ -1062,6 +1097,9 @@ impl State {
10621097 titlebar,
10631098 renaming: None,
10641099 sectionless,
1100 locked,
1101 password: None,
1102 passwords: stored.passwords,
10651103 drag: None,
10661104 templates: templates::View::Strip,
10671105 media: Default::default(),
......@@ -1142,6 +1180,7 @@ impl State {
11421180 eprintln!("{error}");
11431181 }
11441182 lap("open", start);
1183 self.follow_reading();
11451184 let size = self.window.inner_size();
11461185 let scale = self.scale();
11471186 if scale != self.ui.scale() {
......@@ -1170,6 +1209,7 @@ impl State {
11701209 self.ime_allowed = ime;
11711210 self.window.set_ime_allowed(ime);
11721211 }
1212 self.lock_idle()?;
11731213 self.draw()?;
11741214 lap("drawn", start);
11751215 self.sync_index(false, Vec::new());
......@@ -1211,6 +1251,9 @@ impl State {
12111251 self.new_icloud_dialog();
12121252 self.link_dialog()?;
12131253 self.properties_dialog();
1254 self.save_as_dialog();
1255 self.unpack_dialog();
1256 self.password_dialog()?;
12141257 self.customize_tags();
12151258 self.palette();
12161259 self.sync_popup()?;
......@@ -1314,7 +1357,10 @@ impl State {
13141357 let mut theme = self.ui.theme.clone();
13151358 theme.paper = self.paper().color;
13161359 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();
13181364 self.toolbar(&theme, !welcome)?;
13191365 if welcome {
13201366 self.welcome(&theme);
......@@ -1380,7 +1426,9 @@ impl State {
13801426 ..Spec::default()
13811427 },
13821428 );
1429 self.recycle_heading(&theme);
13831430 let row = sections();
1431 let unread = self.unread_keys();
13841432 let (clicked, open_tab) = match &self.session {
13851433 Some(session) => {
13861434 // A tab being renamed takes the name typed, which its field covers.
......@@ -1394,7 +1442,8 @@ impl State {
13941442 }
13951443 _ => tab.name.as_str(),
13961444 };
1397 (name, section_color(tab.color))
1445 let unread = unread.contains(&session.library.key(&tab.path));
1446 (name, section_color(tab.color), unread)
13981447 })
13991448 .collect();
14001449 let lit = self.page_drop(row);
......@@ -1453,8 +1502,9 @@ impl State {
14531502 self.tab_rename_field(&theme, row, tab_row);
14541503 (clicked, open_tab)
14551504 }
1505 None if self.locked.is_some() => self.locked_tabs(row, &section, theme.strip),
14561506 None => {
1457 let tabs = [("Temporary page", section_color(None))];
1507 let tabs = [("Temporary page", section_color(None), false)];
14581508 let shown = if self.temporary { &tabs[..] } else { &[] };
14591509 let open_tab = ui::shell::section_tabs(
14601510 &mut self.ui,
......@@ -1537,6 +1587,22 @@ impl State {
15371587 self.commands
15381588 .extend(conflict_bar(&mut self.ui, bar, &sections, steps));
15391589 }
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 }
15401606 if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) {
15411607 self.no_sections(&theme, library);
15421608 self.ui.close();
......@@ -1816,7 +1882,6 @@ impl State {
18161882 Flags::default()
18171883 },
18181884 size: [fill(), px(TITLE)],
1819 fill: Some(theme.strip),
18201885 pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],
18211886 gap: GAP,
18221887 role: Some(accesskit::Role::Toolbar),
......@@ -1827,6 +1892,20 @@ impl State {
18271892 } else {
18281893 self.ui.open("toolbar", spec)
18291894 };
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 );
18301909 let lead = if title { platform::LEADING } else { FRAME };
18311910 self.ui.leaf(
18321911 "lead",
......@@ -1838,16 +1917,21 @@ impl State {
18381917 if tools {
18391918 // The groups fold in the room the window's controls leave, and whatever still
18401919 // 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);
18421922 self.ui.open(
18431923 "tools",
18441924 Spec {
18451925 flags: Flags::CLIP,
18461926 size: [fill(), px(TITLE)],
1847 pad: [0.0, edge],
1927 pad: [face.pad[0], edge],
18481928 offset: [0.0, -edge],
1849 gap: GAP,
1850 ..Spec::default()
1929 gap: if theme.tool[3] > 0.0 && !panel {
1930 FACE_GAP
1931 } else {
1932 GAP
1933 },
1934 ..face
18511935 },
18521936 );
18531937 self.tools(theme);
......@@ -1861,6 +1945,7 @@ impl State {
18611945 },
18621946 );
18631947 }
1948 self.ui.close();
18641949 if title {
18651950 platform::window_controls(&mut self.ui, &self.window);
18661951 }
......@@ -1878,8 +1963,7 @@ impl State {
18781963 .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0]));
18791964 if narrowest != self.min_width {
18801965 self.min_width = narrowest;
1881 self.window
1882 .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1])));
1966 platform::set_min_size(&self.window, [narrowest, MIN_SIZE[1]]);
18831967 }
18841968 Ok(())
18851969 }
......@@ -2006,7 +2090,7 @@ impl State {
20062090 "font",
20072091 Spec {
20082092 gap: 1.0,
2009 ..Spec::default()
2093 ..tool_face(theme, grouped(theme), FACE_RISE)
20102094 },
20112095 );
20122096 divider(ui, theme);
......@@ -2705,7 +2789,7 @@ impl State {
27052789 eprintln!("{error}");
27062790 }
27072791 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));
27092793 }
27102794 tip(&mut self.ui, commands::Id::NewPage);
27112795 let toggle = if self.pages_open {
......@@ -2749,9 +2833,8 @@ impl State {
27492833 self.ui.close();
27502834 return None;
27512835 }
2752 let found = self
2753 .search
2754 .found_in(&session.library.key(&session.tabs[session.tab].path));
2836 let found = self.search.found_in(&session.key());
2837 let unread = self.unread_pages();
27552838 let rounding = self.rounding();
27562839 let dragged = self.dragged_page();
27572840 let rows = page_rows(
......@@ -2759,7 +2842,7 @@ impl State {
27592842 theme,
27602843 section,
27612844 session,
2762 &found,
2845 [&found, &unread],
27632846 (rounding, self.page_tabs_left),
27642847 self.renaming.as_mut(),
27652848 dragged,
......@@ -2813,7 +2896,8 @@ impl State {
28132896 let response = match event {
28142897 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,
28152898 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));
28172901 continue;
28182902 }
28192903 ui::Event::PointerLeft => self.view.pointer_left(),
......@@ -2900,6 +2984,9 @@ impl State {
29002984 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
29012985 match command {
29022986 Command::OpenSection(library, path) => {
2987 if library.locked(&path) {
2988 return self.show_locked(library, path);
2989 }
29032990 let tab = match &self.session {
29042991 // Back to the open section before another one opened.
29052992 Some(session)
......@@ -2953,7 +3040,7 @@ impl State {
29533040 self.commands.push(Command::OpenPage(page));
29543041 }
29553042 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")?;
29573044 let page = match session.version {
29583045 Some(open) if open == version => {
29593046 session.section.version(session.space, open)?
......@@ -2962,6 +3049,8 @@ impl State {
29623049 };
29633050 if tab == session.tab {
29643051 session.section.import_page(&page, &self.author)?;
3052 session.pages = session.section.pages()?;
3053 self.edited(Vec::new());
29653054 } else {
29663055 let library = Arc::clone(&session.library);
29673056 let path = session.tabs[tab].path.clone();
......@@ -3010,6 +3099,7 @@ impl State {
30103099 self.delete_pages(pages)?;
30113100 self.edited(Vec::new());
30123101 }
3102 Command::Recycle(request) => self.recycle(request)?,
30133103 Command::Pages(edits) => self.edit_pages(edits)?,
30143104 Command::MovePage { space, path } => {
30153105 self.move_page(space, path)?;
......@@ -3108,11 +3198,7 @@ impl State {
31083198 let Some(mut opening) = self.opening.take() else {
31093199 return Ok(());
31103200 };
3111 let paper = canvas::gpu::Paper {
3112 color: self.ui.theme.paper,
3113 ink: self.ui.theme.paper_ink,
3114 }
3115 .colored(opening.editor.page_color());
3201 let paper = paper.colored(opening.editor.page_color());
31163202 if !self
31173203 .view
31183204 .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw)
......@@ -3151,23 +3237,15 @@ impl State {
31513237 .session
31523238 .as_ref()
31533239 .is_none_or(|open| open.library.location != session.library.location);
3154 // A notebook read again after a change replaces the one it was.
3155 match self
3156 .notebooks
3157 .iter_mut()
3158 .find(|library| library.location == session.library.location)
3159 {
3160 Some(library) => *library = Arc::clone(&session.library),
3161 None => self.notebooks.push(Arc::clone(&session.library)),
3162 }
3163 if session.library.in_icloud() && session.library.downloading() {
3164 self.fetch(Arc::clone(&session.library), None);
3165 }
3240 self.list(&session.library);
31663241 // The section left stays open a while for coming back to; the notebook's
31673242 // background sync takes it over once it is closed.
3243 self.locked = None;
31683244 if let Some(previous) = self.session.replace(*session) {
3245 let path = &previous.tabs[previous.tab].path;
3246 let locked = self.leave(&previous.library, path);
31693247 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)) {
31713249 library.keep(&previous.tabs[previous.tab].path, previous.section);
31723250 } else {
31733251 let section = previous.section;
......@@ -3197,7 +3275,7 @@ impl State {
31973275 }
31983276 }
31993277 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();
32013279 self.places.get(&(key, session.space)).copied()
32023280 });
32033281 let left = self.view.open(opening.editor, Some(opening.scene), place);
......@@ -3217,7 +3295,10 @@ impl State {
32173295 self.switching = None;
32183296 self.visited();
32193297 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)
32213302 {
32223303 let target = menus::Target::Page {
32233304 library: Arc::clone(&session.library),
......@@ -3259,17 +3340,7 @@ impl State {
32593340 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.
32603341 /// Without `path` the notebook has no sections left, and one of its shows none.
32613342 fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> {
3262 if library.in_icloud() && library.downloading() {
3263 self.fetch(Arc::clone(&library), None);
3264 }
3265 match self
3266 .notebooks
3267 .iter_mut()
3268 .find(|open| open.location == library.location)
3269 {
3270 Some(open) => *open = Arc::clone(&library),
3271 None => self.notebooks.push(Arc::clone(&library)),
3272 }
3343 self.list(&library);
32733344 let shown = |shown: &Library| shown.location == library.location;
32743345 let Some(path) = path else {
32753346 if self.sectionless.as_deref().is_some_and(shown)
......@@ -3317,6 +3388,18 @@ impl State {
33173388 Ok(())
33183389 }
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
33203403 /// Follows a page shown in place of another.
33213404 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
33223405 if let Err(error) = self.wear_theme() {
......@@ -3369,7 +3452,14 @@ impl State {
33693452 .file_name()
33703453 .map(|name| name.to_string_lossy().into_owned())
33713454 .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())
33733463 }
33743464 None if !self.temporary => "Snowbound".to_owned(),
33753465 None => {
......@@ -3486,7 +3576,10 @@ impl State {
34863576 loop {
34873577 session.section.wake();
34883578 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())
34903583 {
34913584 return;
34923585 }
......@@ -3575,16 +3668,17 @@ impl State {
35753668 /// last poll.
35763669 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
35773670 // Every notebook's changes are taken, so none is reported again.
3578 let changed: Vec<String> = self
3579 .notebooks
3580 .iter()
3581 .filter_map(|library| Some((library, library.background.as_ref()?)))
3582 .flat_map(|(library, background)| {
3583 background
3584 .changed()
3585 .into_iter()
3586 .map(|path| library.key(&path))
3671 let reported: Vec<(Arc<Library>, Vec<String>)> = (self.notebooks.iter())
3672 .filter_map(|library| {
3673 Some((Arc::clone(library), library.background.as_ref()?.changed()))
35873674 })
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)))
35883682 .collect();
35893683 if !changed.is_empty() {
35903684 self.sync_index(false, changed);
......@@ -3597,17 +3691,16 @@ impl State {
35973691 let mut listed = false;
35983692 let mut changed = false;
35993693 let mut rejected = None;
3694 let mut elsewhere = Vec::new();
36003695 for event in session.section.events() {
36013696 use notebook::session::Event;
36023697 match event {
36033698 Event::Changed(spaces) => {
36043699 listed = true;
36053700 changed |= spaces.contains(&session.space);
3606 self.search.changed(
3607 session.library.key(&session.tabs[session.tab].path),
3608 session.section.replica(),
3609 spaces,
3610 );
3701 elsewhere.extend_from_slice(&spaces);
3702 self.search
3703 .changed(session.key(), session.section.replica(), spaces);
36113704 }
36123705 Event::Rejected { spaces, error } => {
36133706 if spaces.contains(&session.space) {
......@@ -3621,6 +3714,12 @@ impl State {
36213714 }
36223715 }
36233716 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 };
36243723 if listed {
36253724 let back = session.shown;
36263725 session.pages = session.section.pages()?;
......@@ -3967,9 +4066,15 @@ impl State {
39674066 })
39684067 .collect();
39694068 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 };
39704075 let start = Instant::now();
39714076 self.renderer
3972 .draw(target, self.surface.size, self.ui.theme.strip, &layers)
4077 .draw(target, self.surface.size, clear, &layers)
39734078 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
39744079 lap("render", start);
39754080 Ok(())
......@@ -3988,9 +4093,6 @@ impl State {
39884093 self.surface.read(&self.renderer, offscreen)
39894094 }
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.
39944096 fn over_page(&self) -> bool {
39954097 let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default();
39964098 let [x, y] = self.pointer;
......@@ -4023,6 +4125,9 @@ impl State {
40234125 }
40244126 }
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.
40264131 fn input(&mut self, event: ui::Event) {
40274132 if let ui::Event::PointerMoved(point) = event {
40284133 self.pointer = point;
......@@ -4335,8 +4440,8 @@ fn page_list_top() -> &'static str {
43354440 "top"
43364441}
43374442
4338/// The page list's rows, a page's conflict pages beneath it while shown and the pages a
4339/// search `found` marked. A `dragged` page's tab follows the pointer over the others, which
4443/// The page list's rows, a page's conflict pages beneath it while shown, and `marked` the
4444/// pages a search found and those unread. A `dragged` page's tab follows the pointer over the others, which
43404445/// slide aside to open the gap it would land in; once let go it eases into its place.
43414446#[allow(clippy::too_many_arguments)]
43424447fn page_rows(
......@@ -4344,7 +4449,7 @@ fn page_rows(
43444449 theme: &Theme,
43454450 section: &ui::Section,
43464451 session: &Session,
4347 found: &HashSet<ExGuid>,
4452 marked: [&HashSet<ExGuid>; 2],
43484453 shape: (f32, bool),
43494454 mut renaming: Option<&mut rename::Renaming>,
43504455 dragged: Option<PageDrag>,
......@@ -4389,7 +4494,7 @@ fn page_rows(
43894494 theme,
43904495 section,
43914496 session,
4392 found,
4497 marked,
43934498 shape,
43944499 renaming.as_deref_mut(),
43954500 (space, title, *level),
......@@ -4416,7 +4521,7 @@ fn page_rows(
44164521 theme,
44174522 section,
44184523 session,
4419 found,
4524 marked,
44204525 shape,
44214526 renaming,
44224527 (space, title, *level),
......@@ -4437,7 +4542,7 @@ fn page_row(
44374542 theme: &Theme,
44384543 section: &ui::Section,
44394544 session: &Session,
4440 found: &HashSet<ExGuid>,
4545 [found, unread]: [&HashSet<ExGuid>; 2],
44414546 shape: (f32, bool),
44424547 renaming: Option<&mut rename::Renaming>,
44434548 (space, title, level): (&ExGuid, &String, u32),
......@@ -4457,6 +4562,7 @@ fn page_row(
44574562 indent: level.saturating_sub(1),
44584563 conflicted: !versions.is_empty(),
44594564 found: found.contains(space),
4565 unread: unread.contains(space),
44604566 renaming: renaming
44614567 .filter(|renaming| renaming.page(*space))
44624568 .map(|renaming| &mut renaming.name),
......@@ -4493,62 +4599,45 @@ fn page_row(
44934599 tab: ui::mix(section.tab, theme.chip, 0.6),
44944600 ..*section
44954601 };
4496 if session.shown_history == Some(*space) {
4497 for version in session.page_versions(*space) {
4498 let label = history::label(version);
4499 let tab = PageTab {
4500 label: &label,
4501 dim: false,
4502 indent: level.saturating_sub(1),
4503 conflicted: false,
4504 found: false,
4505 renaming: None,
4506 shift,
4507 lifted: false,
4602 // Each row: its id, its label, whether it is open, and whether it is a page version.
4603 let history = (session.page_versions(*space).iter())
4604 .filter(|_| session.shown_history == Some(*space))
4605 .map(|version| {
4606 let open = session.version == Some(version.context);
4607 (version.context, history::label(version), open, true)
4608 });
4609 let conflicts = (versions.iter())
4610 .filter(|_| session.shown == Some(*space))
4611 .map(|version| {
4612 let label = match version.created {
4613 Some(created) => format!("{} {}", platform::short_date(created), version.user),
4614 None => version.user.clone(),
45084615 };
4509 let selected = session.version == Some(version.context);
4510 if selected {
4511 rows.open = Some(ui.id(version.context));
4512 }
4513 height += ROW;
4514 if page_tab(ui, theme, &muted, &version.context, tab, selected, shape)
4515 .0
4516 .clicked
4517 && !selected
4518 {
4519 rows.version = Some((*space, version.context));
4520 }
4521 }
4522 }
4523 if session.shown != Some(*space) {
4524 return height;
4525 }
4526 for version in versions {
4527 let label = match version.created {
4528 Some(created) => format!("{} {}", platform::short_date(created), version.user),
4529 None => version.user.clone(),
4530 };
4616 (version.space, label, version.space == session.space, false)
4617 });
4618 for (id, label, selected, version) in history.chain(conflicts) {
45314619 let tab = PageTab {
45324620 label: &label,
45334621 dim: false,
45344622 indent: level.saturating_sub(1),
45354623 conflicted: false,
45364624 found: false,
4625 unread: false,
45374626 renaming: None,
45384627 shift,
45394628 lifted: false,
45404629 };
4541 let selected = version.space == session.space;
45424630 if selected {
4543 rows.open = Some(ui.id(version.space));
4631 rows.open = Some(ui.id(id));
45444632 }
45454633 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)
45474635 .0
4548 .clicked
4549 && !selected
4550 {
4551 rows.clicked = Some(version.space);
4636 .clicked;
4637 if clicked && !selected && version {
4638 rows.version = Some((*space, id));
4639 } else if clicked && !selected {
4640 rows.clicked = Some(id);
45524641 }
45534642 }
45544643 height
......@@ -4564,6 +4653,8 @@ struct PageTab<'a> {
45644653 conflicted: bool,
45654654 /// A search found the page, which OneNote marks yellow.
45664655 found: bool,
4656 /// Another author changed it since it was last viewed, which OneNote sets bold.
4657 unread: bool,
45674658 /// The name typed in the tab's rename field, while it shows one.
45684659 renaming: Option<&'a mut String>,
45694660 /// How far down from its place in the list it is drawn.
......@@ -4652,6 +4743,7 @@ fn page_tab(
46524743 Spec {
46534744 size: [fill(), px(ROW - ROW_GAP)],
46544745 text: Some(tab.label),
4746 bold: tab.unread,
46554747 overflow: ui::Overflow::Ellipsis,
46564748 color: Some(color),
46574749 ..Spec::default()
......@@ -4793,11 +4885,12 @@ fn group(
47934885 full: impl FnOnce(&mut Ui) -> Option<commands::Choice>,
47944886 folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>,
47954887) -> Option<commands::Choice> {
4888 let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE);
47964889 ui.open(
47974890 part,
47984891 Spec {
47994892 fold: Some(priority),
4800 ..Spec::default()
4893 ..face
48014894 },
48024895 );
48034896 ui.open(
......@@ -5010,6 +5103,26 @@ fn dropdown(
50105103 }
50115104}
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
50135126/// Names box `id` to assistive technology, where no tooltip does.
50145127fn name(ui: &mut Ui, id: Id, label: &str) {
50155128 if let Some(node) = ui.access(id) {
......@@ -5179,8 +5292,33 @@ fn title(id: commands::Id) -> &'static str {
51795292 commands::command(id).title.trim_end_matches('…')
51805293}
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.
51835318fn divider(ui: &mut Ui, theme: &Theme) {
5319 if grouped(theme) {
5320 return;
5321 }
51845322 ui.leaf(
51855323 "divider",
51865324 Spec {
......@@ -5312,147 +5450,103 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
53125450
53135451impl ApplicationHandler<UserEvent> for App {
53145452 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 };
53155462 let event = match event {
5463 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop),
53165464 UserEvent::Picture(bytes) => {
5317 if let Some(state) = &mut self.state {
5318 if let Err(error) = state.insert_picture(bytes, None) {
5319 eprintln!("{error}");
5320 }
5321 state.window.request_redraw();
5465 if let Err(error) = state.insert_picture(bytes, None) {
5466 eprintln!("{error}");
53225467 }
5323 return;
5468 return state.window.request_redraw();
53245469 }
53255470 UserEvent::InsertText(text) => {
5326 if let Some(state) = &mut self.state {
5327 if state.ui.focused() == Some(page()) {
5328 match state.view.insert_text(text) {
5329 Ok(response) => state.respond(response),
5330 Err(error) => eprintln!("{error}"),
5331 }
5332 state.window.request_redraw();
5333 } else {
5334 state.input(ui::Event::Ime(Ime::Commit(text)));
5335 }
5336 }
5337 return;
5338 }
5339 UserEvent::Quit => {
5340 self.close(event_loop);
5341 return;
5342 }
5343 UserEvent::Then(then) => {
5344 if let Some(state) = &mut self.state {
5345 if let Err(error) = then(state) {
5346 eprintln!("{error}");
5471 if state.ui.focused() == Some(page()) {
5472 match state.view.insert_text(text) {
5473 Ok(response) => state.respond(response),
5474 Err(error) => eprintln!("{error}"),
53475475 }
53485476 state.window.request_redraw();
5477 } else {
5478 state.input(ui::Event::Ime(Ime::Commit(text)));
53495479 }
53505480 return;
53515481 }
5352 UserEvent::ICloudFolder => {
5353 if let Some(state) = &mut self.state {
5354 state.list_icloud();
5482 UserEvent::Then(then) => {
5483 if let Err(error) = then(state) {
5484 eprintln!("{error}");
53555485 }
5356 return;
5486 return state.window.request_redraw();
53575487 }
5488 UserEvent::ICloudFolder => return state.list_icloud(),
53585489 UserEvent::ICloudAccount => {
5359 if let Some(state) = &mut self.state {
5360 state.icloud_account_changed();
5361 state.window.request_redraw();
5362 }
5363 return;
5490 state.icloud_account_changed();
5491 return state.window.request_redraw();
53645492 }
53655493 UserEvent::Open(paths) => {
5366 match &mut self.state {
5367 Some(state) => {
5368 for path in paths {
5369 state.open_path(&path);
5370 }
5371 state.window.set_minimized(false);
5372 state.window.focus_window();
5373 state.window.request_redraw();
5374 }
5375 // A cold launch's documents arrive before the window opens.
5376 None => self.opening.extend(paths),
5494 for path in paths {
5495 state.open_path(&path);
53775496 }
5378 return;
5497 state.window.set_minimized(false);
5498 state.window.focus_window();
5499 return state.window.request_redraw();
53795500 }
53805501 UserEvent::Update => {
5381 if let Some(state) = &mut self.state {
5382 state.updated();
5383 state.window.request_redraw();
5384 }
5385 return;
5502 state.updated();
5503 return state.window.request_redraw();
53865504 }
53875505 UserEvent::Sync => {
5388 if let Some(state) = &mut self.state
5389 && let Err(error) = state.synced()
5390 {
5506 if let Err(error) = state.synced() {
53915507 eprintln!("{error}");
53925508 }
53935509 return;
53945510 }
5395 UserEvent::Choose(choice) => {
5396 if let Some(state) = &mut self.state {
5397 state.choose(choice);
5398 }
5399 return;
5400 }
5511 UserEvent::Choose(choice) => return state.choose(choice),
54015512 UserEvent::Appearance => {
5402 if let Some(state) = &mut self.state {
5403 state.follow_color_scheme();
5404 state.window.request_redraw();
5405 }
5406 return;
5407 }
5408 UserEvent::Redraw => {
5409 if let Some(state) = &self.state {
5410 state.window.request_redraw();
5411 }
5412 return;
5513 state.follow_color_scheme();
5514 return state.window.request_redraw();
54135515 }
5516 UserEvent::Redraw => return state.window.request_redraw(),
54145517 UserEvent::Replay(replay) => {
5415 if let Some(state) = &mut self.state {
5416 match replay {
5417 Replay::Input(event) => state.input(event),
5418 Replay::Pinch(factor) => {
5419 if let Err(error) = state.pinch(factor) {
5420 eprintln!("{error}");
5421 }
5422 }
5423 Replay::Snapshot(path) => state.snapshot = Some(path),
5424 Replay::Accessibility(path) => {
5425 if let Err(error) = state.write_accessibility(&path) {
5426 eprintln!("{error}");
5427 }
5428 }
5429 Replay::Tick => {}
5430 Replay::Appearance(appearance) => {
5431 state.window.set_theme(Some(appearance));
5432 state.set_appearance(appearance);
5433 }
5434 Replay::Resize([width, height]) => {
5435 let _ = state
5436 .window
5437 .request_inner_size(LogicalSize::new(width, height));
5518 match replay {
5519 Replay::Input(event) => state.input(event),
5520 Replay::Pinch(factor) => {
5521 if let Err(error) = state.pinch(factor) {
5522 eprintln!("{error}");
54385523 }
5439 Replay::Quit => {
5440 self.close(event_loop);
5441 return;
5524 }
5525 Replay::Snapshot(path) => state.snapshot = Some(path),
5526 Replay::Accessibility(path) => {
5527 if let Err(error) = state.write_accessibility(&path) {
5528 eprintln!("{error}");
54425529 }
54435530 }
5444 // A covered window gets no redraws, so each step draws its own frame.
5445 if let Err(error) = state.frame() {
5446 eprintln!("{error}");
5531 Replay::Tick | Replay::Quit => {}
5532 Replay::Appearance(appearance) => {
5533 state.window.set_theme(Some(appearance));
5534 state.set_appearance(appearance);
5535 }
5536 Replay::Resize([width, height]) => {
5537 let _ = state
5538 .window
5539 .request_inner_size(LogicalSize::new(width, height));
54475540 }
54485541 }
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 }
54495546 return;
54505547 }
54515548 UserEvent::Accessibility(event) => event,
54525549 };
5453 let Some(state) = &mut self.state else {
5454 return;
5455 };
54565550 if event.window_id != state.window.id() {
54575551 return;
54585552 }
......@@ -5679,9 +5773,10 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
56795773}
56805774
56815775/// Feeds a development script to the window from another thread, one command per line
5682/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type
5683/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
5684/// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
5776/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`,
5777/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
5778/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`,
5779/// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
56855780fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
56865781 let mut steps = Vec::new();
56875782 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 {
2727 Delete {
2828 path: String,
2929 },
30 /// Deletes for good what the recycle bin holds.
31 EmptyRecycleBin,
3032 /// Moves a section or group into another folder.
3133 Move {
3234 path: String,
......@@ -55,6 +57,12 @@ pub enum Structure {
5557 name: String,
5658 color: Option<u32>,
5759 },
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 },
5866}
5967
6068/// New iCloud Notebook while it is open: the name typed, and why the last was refused.
......@@ -234,7 +242,7 @@ impl State {
234242 }
235243
236244 /// 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>> {
238246 if ops.is_empty() {
239247 return Ok(());
240248 }
......@@ -247,6 +255,7 @@ impl State {
247255 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
248256 },
249257 )?;
258 self.edited(vec![space]);
250259 self.refresh()
251260 }
252261
......@@ -351,22 +360,7 @@ impl State {
351360 },
352361 );
353362 }
354 ui.open(
355 "buttons",
356 Spec {
357 size: [fill(), children()],
358 pad: [0.0, 8.0],
359 gap: 8.0,
360 ..Spec::default()
361 },
362 );
363 ui.leaf(
364 "space",
365 Spec {
366 size: [fill(), px(1.0)],
367 ..Spec::default()
368 },
369 );
363 crate::buttons(ui);
370364 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
371365 let create = ui::button(ui, "create", "Create").clicked || entered;
372366 ui.close();
......@@ -660,23 +654,15 @@ impl State {
660654 library.close_kept();
661655 let mut notebook = library.reopen()?;
662656 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
663 let mut folders = vec![notebook.catalog()];
664 while let Some(candidate) = folders.pop() {
665 if candidate.path == folder {
666 return candidate
667 .sections
668 .iter()
669 .map(|section| section.path.clone())
670 .chain(candidate.groups.iter().map(|group| group.path.clone()))
671 .map(|path| {
672 let name = path.rsplit('/').next().unwrap_or_default();
673 name.strip_suffix(".one").unwrap_or(name).to_owned()
674 })
675 .collect();
676 }
677 folders.extend(&candidate.groups);
678 }
679 Vec::new()
657 let found = crate::library::folders(notebook.catalog(), |_| true)
658 .into_iter()
659 .find(|candidate| candidate.path == folder);
660 found.map_or_else(Vec::new, |folder| {
661 (folder.sections.iter().map(|section| &section.path))
662 .chain(folder.groups.iter().map(|group| &group.path))
663 .map(|path| crate::library::entry_name(path).to_owned())
664 .collect()
665 })
680666 };
681667 // A notebook not shown stays so through a change of its colours or name.
682668 let stays = matches!(
......@@ -709,6 +695,11 @@ impl State {
709695 notebook.delete(&path)?;
710696 (current, None)
711697 }
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 }
712703 Structure::Move { path, folder } => {
713704 let moved = notebook.move_entry(&path, &folder)?;
714705 (current.map(|current| follow(&current, &path, &moved)), None)
......@@ -732,6 +723,24 @@ impl State {
732723 notebook.set_section_color(&path, color)?;
733724 (current, None)
734725 }
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 }
735744 Structure::Properties { name, color } => {
736745 if name != library.name {
737746 library.set_display_name(&name)?;
......@@ -1187,7 +1196,8 @@ mod tests {
11871196 let root = temporary.join("Emptied");
11881197 let location = root.to_str().unwrap();
11891198 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();
11911201 notebook.create_group("", "Group").unwrap();
11921202 notebook.create_section("Group", "Inner", &dated()).unwrap();
11931203
crates/snowbound/src/media_linux.rs+2-22
......@@ -3,7 +3,7 @@
33//! with gst-libav), the default microphone records as PCM WAV, and the default camera with
44//! 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};
77use std::{
88 cell::Cell,
99 ffi::{CStr, CString, c_char, c_int, c_void},
......@@ -50,12 +50,6 @@ struct Gst {
5050unsafe impl Send for Gst {}
5151unsafe 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
5953/// GStreamer, initialized; none where it is not installed.
6054fn gst() -> Option<&'static Gst> {
6155 static GST: OnceLock<Option<Gst>> = OnceLock::new();
......@@ -84,20 +78,6 @@ fn gst() -> Option<&'static Gst> {
8478 .as_ref()
8579}
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
10181/// A pipeline described as `gst-launch` takes one, paused.
10282struct Pipeline {
10383 gst: &'static Gst,
......@@ -191,7 +171,7 @@ impl Player {
191171 };
192172 let pipeline = Pipeline::launch(&format!(
193173 "playbin uri=\"{}\" video-sink=fakesink{silent}",
194 uri(path)
174 crate::platform::file_uri(path)
195175 ))?;
196176 // Prerolling reports a file no plugin decodes as an error.
197177 if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) {
crates/snowbound/src/menubar.rs+12
......@@ -67,6 +67,18 @@ const MENUS: &[Item] = &[
6767 C(Id::CopyPageLink),
6868 C(Id::ShowNotebook),
6969 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),
7082 C(Id::ExportPdf),
7183 S,
7284 C(Id::Print),
crates/snowbound/src/menus.rs+128-21
......@@ -6,6 +6,7 @@ use crate::{
66 commands::{self, Choice},
77 manage::Structure,
88 platform,
9 recycle::Request,
910};
1011use onestore::{ExGuid, PageEdit};
1112use std::sync::Arc;
......@@ -87,6 +88,15 @@ pub enum Action {
8788 Move,
8889 /// Moves it to a section, or into a folder, by catalog path.
8990 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,
90100 CopyLink,
91101 NewPage,
92102 NewSubpage,
......@@ -110,6 +120,8 @@ pub enum Action {
110120 /// Moves a notebook up the notebook list, or down.
111121 Raise(bool),
112122 Properties,
123 /// Opens Password Protection on a section.
124 Password,
113125}
114126
115127/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section
......@@ -259,7 +271,7 @@ impl State {
259271 pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> {
260272 // An action, its label, whether it is disabled, and whether a rule starts its group.
261273 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);
263275 let item = Item {
264276 text,
265277 disabled,
......@@ -271,6 +283,18 @@ impl State {
271283 };
272284 let nowhere = || self.destinations(target).is_empty();
273285 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 ],
274298 Target::Page {
275299 library,
276300 path,
......@@ -321,16 +345,26 @@ impl State {
321345 actions
322346 }
323347 Target::Section { library, path } | Target::Group { library, path } => {
324 let mut actions = vec![
325 item(Action::Rename, "Rename", false, false),
348 let section = matches!(target, Target::Section { .. });
349 let mut actions = vec![item(Action::Rename, "Rename", false, false)];
350 if section {
351 actions.push(item(Action::SaveAs, "Save As…", false, false));
352 }
353 actions.extend([
326354 item(Action::Delete, "Delete", false, false),
327355 item(Action::Move, "Move", nowhere(), false),
328356 item(Action::NewSection, "New Section", false, true),
329357 item(Action::NewGroup, "New Section Group", false, false),
330 ];
331 if matches!(target, Target::Section { .. }) {
358 ]);
359 if section {
332360 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),
334368 item(
335369 Action::Reveal,
336370 platform::SHOW_FILE,
......@@ -345,8 +379,15 @@ impl State {
345379 Target::Notebook(library) => {
346380 let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library));
347381 let last = self.notebooks.len().saturating_sub(1);
382 let unread = self.unread_notebook(library);
348383 vec![
349384 item(Action::Rename, "Rename…", false, false),
385 item(
386 Action::SaveAs,
387 "Save As…",
388 library.catalog().is_none(),
389 false,
390 ),
350391 item(
351392 Action::Sync,
352393 "Sync This Notebook Now",
......@@ -369,6 +410,12 @@ impl State {
369410 false,
370411 false,
371412 ),
413 item(
414 Action::MarkNotebookRead,
415 "Mark Notebook as Read",
416 !unread,
417 true,
418 ),
372419 item(
373420 Action::Raise(true),
374421 "Move Up",
......@@ -388,6 +435,12 @@ impl State {
388435 true,
389436 ),
390437 item(Action::Theme, "Theme…", false, false),
438 item(
439 Action::RecycleBin,
440 "Notebook Recycle Bin",
441 library.catalog().is_none(),
442 true,
443 ),
391444 item(
392445 Action::Properties,
393446 "Properties…",
......@@ -416,13 +469,22 @@ impl State {
416469 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();
417470 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);
418471 let places = id.child("move");
472 let copies = id.child("copy");
419473 let colors = id.child("colors");
420474 ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 {
421475 Action::Move => Some(places),
476 Action::Copy => Some(copies),
422477 Action::Colors => Some(colors),
423478 _ => None,
424479 });
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 }
426488 let destinations = self.destinations(target);
427489 let items: Vec<Item> = (destinations.iter())
428490 .map(|place| Item {
......@@ -432,11 +494,9 @@ impl State {
432494 ..Item::default()
433495 })
434496 .collect();
435 ui::popup::menu(&mut self.ui, places, anchor, &items, None)
436 .map(|index| Action::MoveTo(destinations[index].path.clone()))
437 } else {
438 None
439 };
497 moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None)
498 .map(|index| chosen(destinations[index].path.clone())));
499 }
440500 let colored = match target {
441501 Target::Section { library, path } if self.ui.popup_open(colors) => {
442502 let current = (library.tabs(&folder(path)).into_iter())
......@@ -474,6 +534,17 @@ impl State {
474534 fn destinations(&self, target: &Target) -> Vec<Destination> {
475535 let theme = &self.ui.theme;
476536 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 }
477548 Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter())
478549 .filter(|tab| tab.path != *path)
479550 .map(|tab| Destination {
......@@ -536,6 +607,33 @@ impl State {
536607 (Target::Section { library, path }, Action::Open) => {
537608 Some(Command::OpenSection(library, path))
538609 }
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 }
539637 (Target::Notebook(library), Action::Open) => {
540638 self.open_notebook(library.location.clone(), None);
541639 None
......@@ -665,7 +763,22 @@ impl State {
665763 self.rename(crate::rename::Target::Page(space));
666764 None
667765 }
766 Action::Delete if self.in_recycle_bin() => {
767 Some(Command::Recycle(Request::Purge(vec![space])))
768 }
668769 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 })),
669782 Action::MoveTo(path) => Some(Command::MovePage { space, path }),
670783 Action::CopyLink => {
671784 let copied = self
......@@ -915,15 +1028,9 @@ fn new(action: &Action, folder: String) -> Option<Structure> {
9151028/// `library`'s folders of sections, the notebook's first, then its section groups breadth
9161029/// first, but for the recycle bin; none for a section opened on its own.
9171030pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> {
918 let mut folders: Vec<_> = library.catalog().into_iter().collect();
919 let mut at = 0;
920 while let Some(&folder) = folders.get(at) {
921 folders.extend(
922 (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)),
923 );
924 at += 1;
925 }
926 folders
1031 library.catalog().map_or_else(Vec::new, |catalog| {
1032 crate::library::folders(catalog, |group| !crate::library::recycle_bin(&group.path))
1033 })
9271034}
9281035
9291036#[cfg(test)]
crates/snowbound/src/navigation.rs+1
......@@ -288,6 +288,7 @@ mod tests {
288288 identity: None,
289289 created: None,
290290 margin_origin: [0.0; 2],
291 rtl: false,
291292 color: None,
292293 rule_lines: None,
293294 objects: Vec::new(),
crates/snowbound/src/options.rs+66
......@@ -125,6 +125,28 @@ const SECTIONS: &[Section] = &[
125125 control: Control::Check(|options| &mut options.pen_pressure),
126126 }],
127127 },
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 },
128150 ],
129151 },
130152 Section {
......@@ -210,6 +232,10 @@ pub struct Options {
210232 default_font: DefaultFont,
211233 page_tabs_left: bool,
212234 navigation_bar_left: bool,
235 lock_idle: bool,
236 /// Minutes, as `protection::AFTER` lists them.
237 lock_minutes: u32,
238 lock_on_leave: bool,
213239 pub(crate) keyboard: crate::keys::Keyboard,
214240}
215241
......@@ -241,6 +267,10 @@ fn sizes() -> Id {
241267 id().child("sizes")
242268}
243269
270fn lock_times() -> Id {
271 id().child("lock-times")
272}
273
244274fn font_colors() -> Id {
245275 id().child("font-colors")
246276}
......@@ -298,6 +328,9 @@ impl State {
298328 default_font: self.view.editor.default_font.clone(),
299329 page_tabs_left: self.page_tabs_left,
300330 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,
301334 keyboard: crate::keys::Keyboard::new(),
302335 });
303336 self.ui.open_popup(id());
......@@ -600,6 +633,10 @@ impl State {
600633 self.view.editor.default_font = options.default_font;
601634 self.page_tabs_left = options.page_tabs_left;
602635 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 };
603640 self.updates.set_automatic(options.automatic_updates);
604641 self.show_spelling();
605642 self.follow_color_scheme();
......@@ -679,6 +716,35 @@ fn appearance(state: &mut State, options: &mut Options) {
679716 }
680717}
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
682748fn user_name_field(state: &mut State, options: &mut Options) {
683749 let ui = &mut state.ui;
684750 let theme = &ui.theme;
crates/snowbound/src/pane.rs+1-1
......@@ -929,7 +929,7 @@ impl State {
929929 /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`.
930930 fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> {
931931 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);
933933 // The open page with its subpages, or the page it is a subpage of with its others.
934934 let group: Vec<_> = session
935935 .and_then(|session| {
crates/snowbound/src/prefetch.rs+13
......@@ -38,6 +38,11 @@ impl<K: PartialEq, V> Recent<K, V> {
3838 self.entries.iter().any(|(held, _)| held == key)
3939 }
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
4146 pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> {
4247 self.entries.iter_mut().map(|(_, value)| value)
4348 }
......@@ -143,6 +148,14 @@ impl Prefetch {
143148 }
144149 }
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
146159 /// Whether `target` is new since it was last asked for, noting it.
147160 fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool {
148161 let new = !self.asked.contains(&target);
crates/snowbound/src/print.rs+167-28
......@@ -146,6 +146,14 @@ impl State {
146146 self.ui.open_popup(id());
147147 }
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
149157 /// Builds the dialog while it is open; Print or Export goes on with its choices, which
150158 /// the next opening starts from.
151159 pub(crate) fn print_dialog(&mut self) {
......@@ -247,22 +255,7 @@ impl State {
247255 setup.footer = FOOTERS[at];
248256 }
249257 });
250 ui.open(
251 "buttons",
252 Spec {
253 size: [fill(), children()],
254 pad: [0.0, 8.0],
255 gap: 8.0,
256 ..Spec::default()
257 },
258 );
259 ui.leaf(
260 "space",
261 Spec {
262 size: [fill(), px(1.0)],
263 ..Spec::default()
264 },
265 );
258 crate::buttons(ui);
266259 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
267260 let action = if dialog.export {
268261 "Export…"
......@@ -349,6 +342,7 @@ impl State {
349342 fit_width: setup.fit_width,
350343 footer: setup.footer,
351344 };
345 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);
352346 std::thread::spawn(move || {
353347 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
354348 let mut open = Some(open);
......@@ -359,6 +353,7 @@ impl State {
359353 None => replica.page(space).map_err(Into::into),
360354 })
361355 .collect::<Result<Vec<Page>, Box<dyn Error>>>()?;
356 let this = if whole { printed(this) } else { this };
362357 let mut this = Some((open_tab.1.clone(), this));
363358 let sections = if others.is_empty() {
364359 this.into_iter().collect()
......@@ -377,7 +372,7 @@ impl State {
377372 .into_iter()
378373 .map(|(space, ..)| section.page(space))
379374 .collect::<Result<Vec<_>, _>>()?;
380 Ok((name, pages))
375 Ok((name, printed(pages)))
381376 })
382377 .collect::<Result<Vec<_>, Box<dyn Error>>>()?
383378 };
......@@ -419,18 +414,20 @@ fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> {
419414 start..end
420415}
421416
422/// Every readable section of `library`, catalog path and name, a folder's sections before
423/// its groups', as OneNote orders them.
417/// Every readable section of `library` but the recycle bin's, catalog path and name, in the
418/// order each folder's TOC lists its sections and groups, as OneNote's notebook PDF prints them.
424419fn notebook_tabs(library: &Library) -> Vec<(String, String)> {
425420 fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) {
426 tabs.extend(
427 library
428 .tabs(&folder.path)
429 .into_iter()
430 .map(|tab| (tab.path, tab.name)),
431 );
432 for group in &folder.groups {
433 walk(library, group, tabs);
421 let mut sections = library.tabs(&folder.path);
422 for path in &folder.order {
423 if let Some(at) = sections.iter().position(|tab| tab.path == *path) {
424 let tab = sections.remove(at);
425 tabs.push((tab.path, tab.name));
426 } else if let Some(group) = (folder.groups.iter())
427 .find(|group| group.path == *path && !crate::library::recycle_bin(&group.path))
428 {
429 walk(library, group, tabs);
430 }
434431 }
435432 }
436433 let mut tabs = Vec::new();
......@@ -441,8 +438,17 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> {
441438 tabs
442439}
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
444450/// `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 {
446452 let name: String = title
447453 .chars()
448454 .map(|c| match c {
......@@ -486,4 +492,137 @@ mod tests {
486492 assert_eq!(group(&levels, 3), 3..4);
487493 assert_eq!(group(&levels, 5), 4..6);
488494 }
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 }
489628}
crates/snowbound/src/properties.rs+2-18
......@@ -151,27 +151,11 @@ impl State {
151151 }
152152 label(ui, "Location:");
153153 dim(ui, "location", &dialog.library.location);
154 let folder = dialog.library.folder().map(std::path::Path::to_owned);
155154 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 {
157156 crate::platform::reveal(&dialog.library.location);
158157 }
159 ui.open(
160 "buttons",
161 Spec {
162 size: [fill(), children()],
163 pad: [0.0, 8.0],
164 gap: 8.0,
165 ..Spec::default()
166 },
167 );
168 ui.leaf(
169 "space",
170 Spec {
171 size: [fill(), px(1.0)],
172 ..Spec::default()
173 },
174 );
158 crate::buttons(ui);
175159 let ok = ui::button(ui, "ok", "OK").clicked || entered;
176160 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
177161 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 {
8181 let name = match &target {
8282 Target::Entry { path, in_tab, .. } => {
8383 self.sidebar |= !in_tab;
84 let name = path.rsplit('/').next().unwrap_or_default();
85 name.strip_suffix(".one").unwrap_or(name).to_owned()
84 crate::library::entry_name(path).to_owned()
8685 }
8786 Target::Page(space) => self
8887 .session
......@@ -107,8 +106,7 @@ impl State {
107106 }
108107 match target {
109108 Target::Entry { library, path, .. } => {
110 let old = path.rsplit('/').next().unwrap_or_default();
111 if name != old.strip_suffix(".one").unwrap_or(old) {
109 if name != crate::library::entry_name(&path) {
112110 self.commands.push(Command::Structure(
113111 library,
114112 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(
272272fn sections(library: &Library) -> Vec<String> {
273273 match &library.notebook {
274274 Ok(Some(notebook)) => {
275 let mut paths = Vec::new();
276 let mut folders = vec![notebook.catalog()];
277 while let Some(folder) = folders.pop() {
278 paths.extend(
279 folder
280 .sections
281 .iter()
282 .filter(|section| {
283 matches!(
284 section.state,
285 notebook::discover::SectionState::Readable { .. }
286 )
287 })
288 .map(|section| section.path.clone()),
289 );
290 folders.extend(
291 folder
292 .groups
293 .iter()
294 .filter(|group| !crate::library::recycle_bin(&group.path)),
295 );
296 }
297 paths
275 let enter =
276 |group: &notebook::discover::Folder| !crate::library::recycle_bin(&group.path);
277 crate::library::folders(notebook.catalog(), enter)
278 .into_iter()
279 .flat_map(|folder| &folder.sections)
280 .filter(|section| match section.state {
281 notebook::discover::SectionState::Readable { .. } => true,
282 // OneNote searches a protected section only while it is unlocked.
283 notebook::discover::SectionState::Locked => {
284 library.unlocked(&section.path).is_some()
285 }
286 _ => false,
287 })
288 .map(|section| section.path.clone())
289 .collect()
298290 }
299291 Ok(None) => vec![library.location.clone()],
300292 Err(_) => Vec::new(),
......@@ -339,7 +331,10 @@ fn sync(
339331 Some(notebook) => notebook.read_section(&path)?,
340332 None => onestore::read_file(&file)?,
341333 };
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 };
343338 let modified = |space: ExGuid| {
344339 stored
345340 .iter()
......@@ -485,12 +480,10 @@ impl State {
485480 /// Brings the index to the open notebooks and section when they changed, and when
486481 /// `always` or sections `changed` names, by key, to the section files changed since.
487482 pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) {
488 let open = self.session.as_ref().map(|session| {
489 (
490 session.library.key(&session.tabs[session.tab].path),
491 Arc::downgrade(session.section.replica()),
492 )
493 });
483 let open = self
484 .session
485 .as_ref()
486 .map(|session| (session.key(), Arc::downgrade(session.section.replica())));
494487 let identity: Vec<usize> = self
495488 .notebooks
496489 .iter()
......@@ -511,11 +504,8 @@ impl State {
511504 /// Tells the index an edit changed page `space` of the open section.
512505 pub(crate) fn edited(&self, spaces: Vec<ExGuid>) {
513506 if let Some(session) = &self.session {
514 self.search.changed(
515 session.library.key(&session.tabs[session.tab].path),
516 session.section.replica(),
517 spaces,
518 );
507 self.search
508 .changed(session.key(), session.section.replica(), spaces);
519509 }
520510 }
521511
......@@ -544,7 +534,7 @@ impl State {
544534 let open = self
545535 .session
546536 .as_ref()
547 .map(|session| session.library.key(&session.tabs[session.tab].path))
537 .map(crate::Session::key)
548538 .unwrap_or_default();
549539 let wanted = (query.clone(), self.search.scope, version, open);
550540 if self.search.found_for.as_ref() == Some(&wanted) {
......@@ -673,12 +663,10 @@ impl State {
673663 ) -> Result<(), Box<dyn Error>> {
674664 let (location, path) = section.split_once('\n').unwrap_or_default();
675665 self.search.reveal = Some((space, paragraph));
676 let open = self.session.as_ref().map(|session| {
677 (
678 session.library.key(&session.tabs[session.tab].path),
679 session.space,
680 )
681 });
666 let open = self
667 .session
668 .as_ref()
669 .map(|session| (session.key(), session.space));
682670 match open {
683671 Some((key, shown)) if key == section && shown == space => self.refind(true)?,
684672 Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)),
crates/snowbound/src/settings.rs+26
......@@ -48,6 +48,27 @@ pub struct Settings {
4848 pub keys: std::collections::BTreeMap<String, Vec<String>>,
4949 /// Pages shown lately, latest first, which the palette lists first.
5050 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 }
5172}
5273
5374/// What the toolbar's buttons apply from their menus' last picks.
......@@ -166,6 +187,7 @@ impl crate::State {
166187 servers: self.servers.clone(),
167188 keys: crate::commands::Keymap::current().saved(),
168189 recent: self.trail.recent.clone(),
190 passwords: self.passwords,
169191 };
170192 if let Err(error) = settings.save(path) {
171193 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -244,6 +266,10 @@ mod tests {
244266 n: 1,
245267 },
246268 }],
269 passwords: Passwords {
270 lock_after: None,
271 lock_on_leave: true,
272 },
247273 };
248274 settings.save(&path).unwrap();
249275 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+38-20
......@@ -28,7 +28,7 @@ pub enum Action {
2828 notebook: usize,
2929 path: String,
3030 },
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`.
3232 Fold(String),
3333 NewNotebook,
3434 NewICloudNotebook,
......@@ -45,11 +45,6 @@ pub enum Action {
4545 Unavailable(String, String),
4646}
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
5348/// What the tree's rows read and what they were asked.
5449pub struct Tree<'a> {
5550 theme: &'a Theme,
......@@ -68,6 +63,8 @@ pub struct Tree<'a> {
6863 ghost: Option<Ghost>,
6964 /// Where the gap the dragged row would land in stands.
7065 gap: Option<[f32; 4]>,
66 /// The notebooks, groups and sections holding unread pages, by `Library::key`.
67 unread: HashSet<String>,
7168}
7269
7370/// 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 {
8582 depth: u32,
8683 dim: bool,
8784 selected: Option<[f32; 4]>,
85 bold: bool,
8886}
8987
9088impl<'a> Tree<'a> {
......@@ -105,6 +103,7 @@ impl<'a> Tree<'a> {
105103 lifted: None,
106104 ghost: None,
107105 gap: None,
106 unread: HashSet::new(),
108107 }
109108 }
110109}
......@@ -137,14 +136,13 @@ pub fn header() -> Id {
137136 Id::ROOT.child("sidebar header")
138137}
139138
140/// The sidebar's header row, `header` tall and dragging the window when `drags`, then
139/// The sidebar's header row, as tall as the tab row and dragging the window when `drags`, then
141140/// with `rows` the tree of `notebooks` with the open section marked. The notebook button
142141/// floats over the header's end at the window's edge, the `right` one or the left.
143142fn sidebar(
144143 ui: &mut Ui,
145144 tree: &mut Tree,
146145 notebooks: &[Arc<Library>],
147 header: f32,
148146 drags: bool,
149147 rows: bool,
150148 right: bool,
......@@ -158,8 +156,8 @@ fn sidebar(
158156 } else {
159157 Flags::default()
160158 },
161 size: [fill(), px(header)],
162 pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0 + DROP],
159 size: [fill(), px(RAIL)],
160 pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP],
163161 gap: 6.0,
164162 ..Spec::default()
165163 },
......@@ -204,7 +202,7 @@ fn sidebar(
204202 },
205203 );
206204 for (index, library) in notebooks.iter().enumerate() {
207 let key = fold_key(library, "");
205 let key = library.key("");
208206 let unfolded = !folded.contains(&key);
209207 let (row, fold) = tree_row(
210208 ui,
......@@ -222,6 +220,7 @@ fn sidebar(
222220 fold: Some(unfolded),
223221 selected: None,
224222 renamed: false,
223 bold: tree.unread.contains(&key),
225224 },
226225 );
227226 let unsigned = library.notebook.is_err()
......@@ -281,6 +280,7 @@ fn sidebar(
281280 fold: None,
282281 selected: None,
283282 renamed: false,
283 bold: false,
284284 },
285285 );
286286 if row.clicked {
......@@ -315,6 +315,12 @@ fn folder(
315315 *color,
316316 true,
317317 ),
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 ),
318324 _ => (
319325 crate::library::section_name(&section.path, &None),
320326 None,
......@@ -332,6 +338,7 @@ fn folder(
332338 fold: None,
333339 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),
334340 renamed: renaming,
341 bold: tree.unread.contains(&library.key(&section.path)),
335342 };
336343 if lifts(tree, notebook, &section.path, &row) {
337344 continue;
......@@ -357,7 +364,7 @@ fn folder(
357364 if crate::library::recycle_bin(&group.path) {
358365 continue;
359366 }
360 let key = fold_key(library, &group.path);
367 let key = library.key(&group.path);
361368 let unfolded = !tree.folded.contains(&key);
362369 let name = group.path.rsplit('/').next().unwrap_or_default();
363370 gap(ui, tree, &folder.path, Some(&group.path));
......@@ -371,6 +378,7 @@ fn folder(
371378 fold: Some(unfolded),
372379 selected: None,
373380 renamed: renaming,
381 bold: tree.unread.contains(&key),
374382 };
375383 // A dragged group's rows fold away beneath it.
376384 let lifted = lifts(tree, notebook, &group.path, &row);
......@@ -425,6 +433,7 @@ fn folder(
425433 fold: None,
426434 selected: None,
427435 renamed: false,
436 bold: false,
428437 },
429438 );
430439 if row.clicked {
......@@ -499,6 +508,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool {
499508 depth: row.depth,
500509 dim: row.dim,
501510 selected: row.selected,
511 bold: row.bold,
502512 });
503513 }
504514 lifted
......@@ -594,6 +604,8 @@ struct Row<'a> {
594604 selected: Option<[f32; 4]>,
595605 /// The row is being renamed: its label is the tree's rename field.
596606 renamed: bool,
607 /// It holds unread pages, which OneNote sets bold.
608 bold: bool,
597609}
598610
599611/// 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) {
653665 Spec {
654666 size: [fill(), px(ROW)],
655667 text: Some(row.label),
668 bold: row.bold,
656669 color: Some(color),
657670 ..Spec::default()
658671 },
......@@ -729,15 +742,22 @@ impl crate::State {
729742 },
730743 );
731744 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)| {
733751 let index = self
734752 .notebooks
735753 .iter()
736 .position(|library| Arc::ptr_eq(library, &session.library))?;
737 Some((index, session.tabs[session.tab].path.as_str()))
754 .position(|library| Arc::ptr_eq(library, shown))?;
755 Some((index, path))
738756 });
739757 let drags = self.chrome_drags();
758 let unread = self.unread_keys();
740759 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());
760 tree.unread = unread;
741761 tree.lifted = self
742762 .drag
743763 .as_ref()
......@@ -750,7 +770,6 @@ impl crate::State {
750770 &mut self.ui,
751771 &mut tree,
752772 &self.notebooks,
753 crate::TAB_ROW,
754773 drags,
755774 width > 0.5,
756775 self.navigation_bar_right,
......@@ -800,9 +819,6 @@ impl crate::State {
800819 }
801820 }
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.
806822 /// The notebook button, floating at the body's corner over the section tabs' row, `height`
807823 /// tall as it eases, or over the sidebar's header while that is open.
808824 pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) {
......@@ -903,6 +919,7 @@ impl crate::State {
903919 fold: None,
904920 selected: ghost.selected,
905921 renamed: false,
922 bold: ghost.bold,
906923 },
907924 );
908925 self.ui.close();
......@@ -1049,9 +1066,10 @@ impl crate::State {
10491066 self.commands
10501067 .push(crate::Command::OpenSection(library, path));
10511068 }
1069 crate::library::Located::Package(package) => self.open_unpack(&package),
10521070 crate::library::Located::Nothing => crate::platform::alert(
10531071 "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.",
10551073 ),
10561074 }
10571075 }
crates/snowbound/src/spell_linux.rs+1-6
......@@ -1,6 +1,7 @@
11//! Spelling through Enchant, loaded at run time with whichever dictionaries its providers
22//! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked.
33
4use crate::platform::symbol;
45use canvas::spelling::{Dictionary, pick};
56use std::collections::HashMap;
67use std::ffi::{CStr, CString, c_char, c_int, c_void};
......@@ -98,12 +99,6 @@ impl Dictionary for Checker {
9899 }
99100}
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
107102unsafe extern "C" fn describe(
108103 tag: *const c_char,
109104 _: *const c_char,
crates/snowbound/src/surface_gl.rs+11-11
......@@ -92,36 +92,36 @@ impl Surface {
9292 }
9393
9494 /// 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>> {
9696 if self.size.contains(&0) {
9797 return Ok(None);
9898 }
9999 let target = match self.target.take() {
100100 Some(target) if target.size() == self.size => target,
101 _ => Target::new(self.size)?,
101 _ => renderer.target(self.size)?,
102102 };
103103 Ok(Some(Frame { target }))
104104 }
105105
106106 pub fn present(&mut self, renderer: &Renderer, frame: Frame) {
107 if self.translucent {
108 renderer.present_translucent(&frame.target);
109 } else {
110 frame.target.present();
111 }
107 renderer.present(&frame.target, self.translucent);
112108 let _: () = unsafe { msg_send![&self.context, flushBuffer] };
113109 self.target = Some(frame.target);
114110 }
115111
116112 /// 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>> {
118114 Ok(Frame {
119 target: Target::new(self.size)?,
115 target: renderer.target(self.size)?,
120116 })
121117 }
122118
123119 /// The offscreen target's sRGB RGBA rows, top first.
124 pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> {
125 Ok(offscreen.target.read_pixels())
120 pub fn read(
121 &self,
122 renderer: &Renderer,
123 offscreen: Offscreen,
124 ) -> Result<Vec<u8>, Box<dyn Error>> {
125 Ok(renderer.read_pixels(&offscreen.target)?)
126126 }
127127}
crates/snowbound/src/surface_windows.rs+50-40
......@@ -1,11 +1,13 @@
1//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and
2//! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB
3//! target, copied to the window's back buffer and swapped.
1//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise
2//! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL
3//! context. The last two draw into an sRGB target, copied to the window's back buffer.
4//! `SNOWBOUND_RENDERER` set to `d3d11` or `gl` picks one of those, and to anything else
5//! insists on Direct3D 12.
46
57#[path = "surface.rs"]
68mod webgpu;
79
8use draw::{GlTarget, Renderer, Target};
10use draw::{Renderer, Target};
911use std::{error::Error, sync::Arc};
1012use windows_sys::Win32::{
1113 Foundation::HWND,
......@@ -27,13 +29,14 @@ pub struct Surface {
2729
2830enum Backend {
2931 Wgpu(Box<webgpu::Surface>),
30 Gl {
31 /// The window's device context, which the context draws to.
32 device: HDC,
32 /// Direct3D 11 or OpenGL, which present the renderer's own targets.
33 Native {
34 /// For OpenGL, the window's device context, which the context draws to.
35 opengl: Option<HDC>,
3336 /// Frames leave the desktop's glass showing through their transparent pixels.
3437 translucent: bool,
3538 /// The frame target, kept between frames.
36 target: Option<GlTarget>,
39 target: Option<Target>,
3740 },
3841}
3942
......@@ -57,22 +60,42 @@ impl Surface {
5760 backdrop: bool,
5861 ) -> Result<(Self, Renderer), Box<dyn Error>> {
5962 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")) {
6175 match webgpu::Surface::new(window.clone(), backdrop).await {
6276 Ok((surface, renderer)) => {
6377 let backend = Backend::Wgpu(Box::new(surface));
64 let size = window.inner_size();
65 let size = [size.width, size.height];
6678 return Ok((Self { size, backend }, renderer));
6779 }
6880 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})"),
7082 }
7183 }
7284 let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else {
7385 unreachable!()
7486 };
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) };
7699 let request = |alpha| gl::PIXELFORMATDESCRIPTOR {
77100 nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16,
78101 nVersion: 1,
......@@ -147,25 +170,14 @@ impl Surface {
147170 the driver from your graphics card's maker. ({error})"
148171 )
149172 })?;
150 let size = window.inner_size();
151 Ok((
152 Self {
153 size: [size.width, size.height],
154 backend: Backend::Gl {
155 device,
156 translucent,
157 target: None,
158 },
159 },
160 renderer,
161 ))
173 native(Some(device), translucent, renderer)
162174 }
163175
164176 /// Whether frames leave the system's backdrop showing.
165177 pub fn translucent(&self) -> bool {
166178 match &self.backend {
167179 Backend::Wgpu(surface) => surface.translucent(),
168 Backend::Gl { translucent, .. } => *translucent,
180 Backend::Native { translucent, .. } => *translucent,
169181 }
170182 }
171183
......@@ -189,13 +201,13 @@ impl Surface {
189201 texture: Some((frame.texture, frame.reconfigure)),
190202 }))
191203 }
192 Backend::Gl { target, .. } => {
204 Backend::Native { target, .. } => {
193205 let target = match target.take() {
194206 Some(target) if target.size() == self.size => target,
195 _ => GlTarget::new(self.size)?,
207 _ => renderer.target(self.size)?,
196208 };
197209 Ok(Some(Frame {
198 target: target.into(),
210 target,
199211 texture: None,
200212 }))
201213 }
......@@ -213,20 +225,18 @@ impl Surface {
213225 surface.present(renderer, frame);
214226 }
215227 (
216 Backend::Gl {
217 device,
228 Backend::Native {
229 opengl,
218230 translucent,
219231 target: kept,
220232 },
221 Target::Gl(target),
233 target,
222234 None,
223235 ) => {
224 if *translucent {
225 renderer.present_translucent(&target);
226 } else {
227 target.present();
236 renderer.present(&target, *translucent);
237 if let Some(device) = opengl {
238 unsafe { gl::SwapBuffers(*device) };
228239 }
229 unsafe { gl::SwapBuffers(*device) };
230240 *kept = Some(target);
231241 }
232242 _ => unreachable!("Frames come from the surface's own backend"),
......@@ -244,8 +254,8 @@ impl Surface {
244254 texture: Some(offscreen.texture),
245255 })
246256 }
247 Backend::Gl { .. } => Ok(Offscreen {
248 target: GlTarget::new(self.size)?.into(),
257 Backend::Native { .. } => Ok(Offscreen {
258 target: renderer.target(self.size)?,
249259 texture: None,
250260 }),
251261 }
......@@ -261,7 +271,7 @@ impl Surface {
261271 (Backend::Wgpu(surface), target, Some(texture)) => {
262272 surface.read(renderer, webgpu::Offscreen { target, texture })
263273 }
264 (Backend::Gl { .. }, Target::Gl(target), None) => Ok(target.read_pixels()),
274 (Backend::Native { .. }, target, None) => Ok(renderer.read_pixels(&target)?),
265275 _ => unreachable!("Snapshots come from the surface's own backend"),
266276 }
267277 }
crates/snowbound/src/sync.rs+417-108
......@@ -39,7 +39,7 @@ fn describe(
3939 quiet: bool,
4040) -> (&'static str, &'static [&'static str]) {
4141 if offline {
42 return ("Working offline", art::SYNC_OFFLINE);
42 return ("Offline", art::SYNC_OFFLINE);
4343 }
4444 match sync.state() {
4545 SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR),
......@@ -78,14 +78,6 @@ fn brief(sync: &SyncStatus, offline: bool) -> String {
7878/// out the recycle bin, unless something is wrong there.
7979fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> {
8080 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 };
8981 let mut sections = library
9082 .background
9183 .as_ref()
......@@ -120,13 +112,21 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus {
120112 .map(|(_, sync)| sync.synced)
121113 .collect::<Option<Vec<_>>>()
122114 .and_then(|times| times.into_iter().min()),
123 error: worst
124 .and_then(|sync| sync.error.as_ref())
125 .map(|error| std::io::Error::new(error.kind(), error.to_string())),
115 error: worst.and_then(|sync| copy(sync).error),
126116 queued: sections.iter().map(|(_, sync)| sync.queued).sum(),
127117 }
128118}
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
130130fn changes(count: u64) -> String {
131131 match count {
132132 1 => "1 change".to_owned(),
......@@ -175,8 +175,8 @@ const TITLE: &str = "Notebook Sync Status";
175175fn update_note(update: &update::Status) -> Option<String> {
176176 match update {
177177 update::Status::Downloading(version) => Some(format!("Downloading {version}…")),
178 update::Status::Ready(version, _) => Some(format!("{version} is ready")),
179 update::Status::Available(version) => Some(format!("{version} is available")),
178 update::Status::Ready(version, ..) => Some(format!("{version} is ready")),
179 update::Status::Available(version, ..) => Some(format!("{version} is available")),
180180 _ => None,
181181 }
182182}
......@@ -209,7 +209,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t
209209 );
210210 let waiting = matches!(
211211 update,
212 update::Status::Ready(..) | update::Status::Available(_)
212 update::Status::Ready(..) | update::Status::Available(..)
213213 );
214214 if waiting {
215215 ui.leaf(
......@@ -259,6 +259,8 @@ struct Facts<'a> {
259259 conflicts: Vec<(ExGuid, &'a str)>,
260260 offline: bool,
261261 update: &'a update::Status,
262 /// Whether the update's changes are listed.
263 changes_listed: bool,
262264 /// Whether the open section's file can be shown in the file manager.
263265 local: bool,
264266}
......@@ -273,6 +275,126 @@ struct Picked {
273275 conflict: Option<ExGuid>,
274276 build_folder: bool,
275277 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
276398}
277399
278400/// Lays out the open popup's contents.
......@@ -320,10 +442,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
320442 let waiting = changes(sync.queued);
321443 let conflict;
322444 let advice = if facts.offline {
323 Some(match sync.queued {
324 0 => "Turn off Work offline to sync.".to_owned(),
325 _ => format!("{waiting} will sync when you go back online."),
326 })
445 None
327446 } else {
328447 match state {
329448 SyncState::NotConnected => Some(match sync.queued {
......@@ -354,7 +473,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
354473 icon = art::SYNC_WARNING;
355474 }
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.
358479 ui.open(
359480 "status",
360481 Spec {
......@@ -367,10 +488,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
367488 if let Some(node) = ui.access(ui.current()) {
368489 node.set_live(accesskit::Live::Polite);
369490 }
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 );
370500 ui.leaf(
371501 "headline",
372502 Spec {
373 size: [fill(), px(theme.font_size * 2.0)],
503 size: [fill(), px(height)],
374504 text: Some(headline),
375505 icon: Some(icon),
376506 font_size: Some(theme.font_size * 1.2),
......@@ -379,12 +509,118 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
379509 ..Spec::default()
380510 },
381511 );
382 if showing {
383 let fraction = match total {
384 0 => 1.0,
385 _ if state == SyncState::UpToDate => 1.0,
386 _ => done as f32 / total as f32,
512 let switch = ui.open(
513 "offline",
514 Spec {
515 flags: Flags::CLICKABLE,
516 size: [children(), px(height)],
517 gap: 8.0,
518 role: Some(accesskit::Role::Switch),
519 ..Spec::default()
520 },
521 );
522 if let Some(node) = ui.access(switch) {
523 node.set_label("Work offline");
524 node.set_toggled(facts.offline.into());
525 }
526 let signal = ui.signal(switch);
527 picked.offline = signal.clicked;
528 ui.leaf(
529 "label",
530 Spec {
531 size: [fit(), px(height)],
532 text: Some("Work offline"),
533 color: Some(theme.text_dim),
534 ..Spec::default()
535 },
536 );
537 let [width, side] = [30.0, 18.0];
538 let on = ui.animate(switch.child("on"), f32::from(u8::from(facts.offline)));
539 let mix = |from: [f32; 4], to: [f32; 4]| {
540 std::array::from_fn(|at| from[at] + (to[at] - from[at]) * on)
541 };
542 ui.open(
543 "track",
544 Spec {
545 size: [px(width), px(height)],
546 inset: [0.0, (height - side) / 2.0, 0.0, (height - side) / 2.0],
547 fill: Some(mix(theme.chip, theme.accent)),
548 border: signal.hovered.then_some(theme.accent),
549 radius: side / 2.0,
550 ..Spec::default()
551 },
552 );
553 let knob = side - 4.0;
554 let left = 2.0 + (width - side) * on;
555 let top = (height - side) / 2.0 + 2.0;
556 ui.mark([left, top, left + knob, top + knob], [1.0; 4], knob / 2.0);
557 ui.close();
558 ui.close();
559 ui.close();
560 if let Some(advice) = &advice {
561 text(ui, "advice", advice, theme.text);
562 } else if !facts.conflicts.is_empty() && !showing {
563 text(
564 ui,
565 "advice",
566 "Both versions are kept. Open the page to compare them.",
567 theme.text,
568 );
569 }
570 let fraction = match total {
571 0 => 1.0,
572 _ if state == SyncState::UpToDate => 1.0,
573 _ => done as f32 / total as f32,
574 };
575 let subtitle = if showing {
576 let mut caption = match total {
577 0 | 1 => String::new(),
578 _ => format!("{done} of {total} sections synced"),
579 };
580 if sync.queued > 0 {
581 if !caption.is_empty() {
582 caption += " · ";
583 }
584 caption += &format!("{waiting} to send");
585 }
586 caption
587 } else if facts.offline && sync.queued > 0 {
588 format!("{waiting} waiting")
589 } else if let Some(synced) = sync.synced {
590 ui.wake_after(Duration::from_secs(30));
591 let prefix = match advice.is_some() || facts.offline {
592 true => "Last synced",
593 false => "Synced",
387594 };
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 {
388624 let fraction = ui.animate(bar.child("fraction"), fraction);
389625 let running = ui.lasted(bar.child("running"), true).as_secs_f32();
390626 ui.open_as(
......@@ -419,48 +655,18 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
419655 node.set_numeric_value(f64::from((fraction * 100.0).round()));
420656 }
421657 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);
436658 } else {
437 if let Some(advice) = &advice {
438 text(ui, "advice", advice, theme.text);
439 } else if !facts.conflicts.is_empty() && headline != "Syncing…" {
440 text(
441 ui,
442 "advice",
443 "Both versions are kept. Open the page to compare them.",
444 theme.text,
445 );
446 }
447 if let Some(synced) = sync.synced {
448 let prefix = match advice {
449 Some(_) => "Last synced",
450 None => "Synced",
451 };
452 text(
453 ui,
454 "synced",
455 &format!("{prefix} {}", ago(synced)),
456 theme.text_dim,
457 );
458 ui.wake_after(Duration::from_secs(30));
459 }
659 ui.leaf(
660 "rule",
661 Spec {
662 size: [fill(), px(6.0)],
663 inset: [0.0, 2.5, 0.0, 2.5],
664 fill: Some(theme.chip),
665 ..Spec::default()
666 },
667 );
460668 }
461669 ui.close();
462
463 rule(ui, "identity-rule");
464670 // Which notebook, and where it lives; the full location in the tooltip.
465671 ui.open(
466672 "identity",
......@@ -528,9 +734,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
528734 );
529735 // Where the whole notebook can't be reached, the headline says so for each section
530736 // without changes waiting.
531 let unreached = |sync: &SyncStatus| {
532 !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0
533 };
737 let unreached = |sync: &SyncStatus| sync.state() == SyncState::NotConnected && sync.queued == 0;
534738 let behind: Vec<_> = facts
535739 .sections
536740 .iter()
......@@ -650,17 +854,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
650854 ui.open(
651855 "update",
652856 Spec {
857 axis: Axis::Y,
653858 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],
654875 gap: 8.0,
655876 ..Spec::default()
656877 },
657878 );
658879 ui.leaf(
659 "note",
880 "space",
660881 Spec {
661 size: [fill(), px(theme.font_size * 2.0)],
662 text: Some(&format!("Snowbound {note}")),
663 overflow: Overflow::Ellipsis,
882 size: [fill(), px(1.0)],
664883 ..Spec::default()
665884 },
666885 );
......@@ -669,6 +888,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
669888 picked.restart = ui::button(ui, "restart", "Restart to Update").clicked;
670889 }
671890 ui.close();
891 ui.close();
672892 }
673893
674894 ui.open(
......@@ -680,7 +900,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
680900 ..Spec::default()
681901 },
682902 );
683 picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked;
684903 ui.leaf(
685904 "space",
686905 Spec {
......@@ -688,30 +907,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
688907 ..Spec::default()
689908 },
690909 );
691 picked.show_file = match facts.local {
692 true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked,
693 false => {
694 ui.leaf(
695 "show-file",
696 Spec {
697 size: [fit(), px(theme.font_size * 2.0)],
698 text: Some(platform::SHOW_FILE),
699 color: Some(theme.text_dim),
700 fill: Some(theme.chip),
701 radius: 4.0,
702 pad: [theme.font_size * 0.75, 0.0],
703 center: true,
704 role: Some(accesskit::Role::Button),
705 ..Spec::default()
706 },
707 );
708 if let Some(node) = ui.access(ui.id("show-file")) {
709 node.set_disabled();
710 }
711 false
910 // A button that can't act now, dimmed.
911 let button = |ui: &mut Ui, part: &str, label: &str, enabled: bool| {
912 if enabled {
913 return ui::button(ui, part, label).clicked;
712914 }
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
713933 };
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);
715936 ui.close();
716937 picked
717938}
......@@ -817,6 +1038,7 @@ impl State {
8171038 .collect(),
8181039 offline,
8191040 update: &update,
1041 changes_listed: self.updates.changes_listed,
8201042 local: file.is_some(),
8211043 };
8221044 open(ui, anchor);
......@@ -852,13 +1074,14 @@ impl State {
8521074 }
8531075 match update {
8541076 update::Status::Downloading(version)
855 | update::Status::Ready(version, _)
856 | update::Status::Available(version)
1077 | update::Status::Ready(version, ..)
1078 | update::Status::Available(version, ..)
8571079 if picked.build_folder =>
8581080 {
8591081 update::show_build(&version)
8601082 }
8611083 _ if picked.restart => self.restart_to_update(),
1084 _ if picked.list_changes => self.updates.changes_listed ^= true,
8621085 _ => {}
8631086 }
8641087 Ok(())
......@@ -915,6 +1138,7 @@ mod tests {
9151138 conflicts: Vec::new(),
9161139 offline: false,
9171140 update: &IDLE,
1141 changes_listed: false,
9181142 local: true,
9191143 }
9201144 }
......@@ -1110,16 +1334,11 @@ mod tests {
11101334 assert!(!bar(&ui));
11111335 }
11121336
1113 #[cfg(all(feature = "wgpu", not(windows)))]
1114 #[test]
1115 fn popup_renders_each_state() {
1116 let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from);
1117 let mut gpu = output.as_ref().map(|output| {
1118 std::fs::create_dir_all(output).unwrap();
1119 Gpu::new()
1120 });
1121 type Made = fn() -> Facts<'static>;
1122 let states: Vec<(&str, Made)> = vec![
1337 type Made = fn() -> Facts<'static>;
1338
1339 /// Each state the popup shows, by name.
1340 fn states() -> Vec<(&'static str, Made)> {
1341 vec![
11231342 ("up-to-date", || facts(sections(|_| status(true, 0, None)))),
11241343 ("syncing", || {
11251344 facts(sections(|index| match index {
......@@ -1178,7 +1397,71 @@ mod tests {
11781397 place: vec!["iCloud Drive".into(), "Notes".into()],
11791398 ..facts(sections(|_| status(true, 0, None)))
11801399 }),
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();
11821465 for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] {
11831466 for (name, facts) in &states {
11841467 let mut ui = ui(appearance);
......@@ -1226,6 +1509,32 @@ mod tests {
12261509 shot += 1;
12271510 }
12281511 }
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 }
12291538 }
12301539 }
12311540}
crates/snowbound/src/tags.rs+2-22
......@@ -345,26 +345,6 @@ fn picker(
345345 Anchor::Below(ui.rect(button).unwrap_or_default())
346346}
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
368348/// The frame of a dialog titled `title`, centred in the window.
369349fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) {
370350 let theme = ui.theme.clone();
......@@ -545,7 +525,7 @@ impl State {
545525 .flatten()
546526 });
547527 ui.close();
548 buttons(ui);
528 crate::buttons(ui);
549529 let ok = button(ui, "ok", "OK", list.tags != self.tags);
550530 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
551531 ui.close();
......@@ -880,7 +860,7 @@ fn tag_editor(
880860 ..Spec::default()
881861 },
882862 );
883 buttons(ui);
863 crate::buttons(ui);
884864 let named = !tag.label.trim().is_empty();
885865 let ok = button(ui, "ok", "OK", named) || entered && named;
886866 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
crates/snowbound/src/themes.rs+1-16
......@@ -156,22 +156,7 @@ impl State {
156156 return Ok(());
157157 }
158158 self.persist()?;
159 let Some(session) = &self.session else {
160 return Ok(());
161 };
162 let space = session.space;
163 session.section.apply(
164 &self.author,
165 onestore::op::Edit {
166 at: filetime(),
167 ops: ops
168 .into_iter()
169 .map(|op| onestore::op::Op::Page { space, op })
170 .collect(),
171 },
172 )?;
173 self.edited(vec![space]);
174 self.refresh()
159 self.edit_page(ops)
175160 }
176161
177162 /// Opens the Themes dialog choosing a theme for `scope` of the open page.
crates/snowbound/src/undo.rs+53-59
......@@ -3,6 +3,7 @@
33//! window keeps what was done to pages and sections between those edits, so Undo takes back
44//! whichever came last where the user is. Every step taken back is an edit of its own.
55
6use crate::library::{entry_name, folders};
67use crate::{Command, Library, Loaded, State, UserEvent};
78use canvas::editor::CanvasEditor;
89use notebook::{Replica, discover::Folder};
......@@ -287,7 +288,7 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) {
287288}
288289
289290/// Whether `page` holds nothing but its title, untitled.
290fn blank(page: &Page) -> bool {
291pub(crate) fn blank(page: &Page) -> bool {
291292 page.title.trim().is_empty()
292293 && page
293294 .objects
......@@ -353,6 +354,46 @@ impl Site {
353354 Ok(())
354355 }
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
356397 /// The catalog path of the section `identity` names.
357398 fn section_path(&self, identity: [u8; 16]) -> Option<String> {
358399 entry_of(self.library.catalog()?, identity).map(|(_, path)| path)
......@@ -397,14 +438,7 @@ impl Site {
397438 })
398439 .collect();
399440 let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))];
400 let (show, added) = match neighbor(&listed, &pages, show) {
401 Some(show) => (show, None),
402 None => {
403 let space = self.fresh.space();
404 ops.push(Op::Section(SectionOp::Create(self.fresh.clone())));
405 (space, Some(space))
406 }
407 };
441 let (show, added) = self.after(&listed, &pages, show, &mut ops);
408442 if !created && binned {
409443 let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect();
410444 self.library.reopen()?.recycle_pages(&pages, &self.author)?;
......@@ -452,15 +486,7 @@ impl Site {
452486 )?])));
453487 spaces.push(space);
454488 }
455 let gone: Vec<ExGuid> = added
456 .filter(|added| {
457 at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p))
458 })
459 .into_iter()
460 .collect();
461 if !gone.is_empty() {
462 ops.push(Op::Section(SectionOp::Delete(gone.clone())));
463 }
489 let gone = self.take_added(added, &listed, &mut ops);
464490 self.apply(ops)?;
465491 if !created && binned {
466492 let identities: Vec<[u8; 16]> =
......@@ -531,14 +557,7 @@ impl Site {
531557 let before = listed[from + 1..].first().map(|(space, ..)| *space);
532558 let level = listed[from].2;
533559 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];
534 let (show, added) = match neighbor(&listed, &[page], None) {
535 Some(show) => (show, None),
536 None => {
537 let space = self.fresh.space();
538 ops.push(Op::Section(SectionOp::Create(self.fresh.clone())));
539 (space, Some(space))
540 }
541 };
560 let (show, added) = self.after(&listed, &[page], None, &mut ops);
542561 let section = self.library.open(&path, || {})?;
543562 section.replica().apply(
544563 &self.author,
......@@ -590,15 +609,7 @@ impl Site {
590609 level,
591610 )?])),
592611 ];
593 let gone: Vec<ExGuid> = added
594 .filter(|added| {
595 at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p))
596 })
597 .into_iter()
598 .collect();
599 if !gone.is_empty() {
600 ops.push(Op::Section(SectionOp::Delete(gone.clone())));
601 }
612 let gone = self.take_added(added, &listed, &mut ops);
602613 self.apply(ops)?;
603614 section.replica().apply(
604615 &self.author,
......@@ -641,21 +652,10 @@ fn folder_id(folder: &Folder) -> Option<[u8; 16]> {
641652 (!folder.path.is_empty()).then_some(toc.file_id)
642653}
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
655655/// The folder `identity` names, the notebook's own without one.
656656fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> {
657657 match identity {
658 Some(_) => folders(catalog)
658 Some(_) => folders(catalog, |_| true)
659659 .into_iter()
660660 .find(|folder| folder_id(folder) == identity),
661661 None => Some(catalog),
......@@ -664,7 +664,7 @@ fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> {
664664
665665/// The section or group `identity` names: its folder and catalog path.
666666fn 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| {
668668 let (_, path) = entries(folder)
669669 .into_iter()
670670 .find(|(id, _)| *id == identity)?;
......@@ -674,18 +674,12 @@ fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> {
674674
675675/// The identity of the section or group at catalog `path`.
676676fn 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| {
678678 let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?;
679679 Some(id)
680680 })
681681}
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
689683/// The action taking back `change` to `library`'s sections and groups, which is about to be
690684/// made; none for changes Undo does not take back, as OneNote 2010 takes back none.
691685pub 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
695689 Structure::Rename { path, name } => Change::Rename {
696690 entry: identity_at(catalog, path)?,
697691 from: name.clone(),
698 to: name_of(path).to_owned(),
692 to: entry_name(path).to_owned(),
699693 },
700694 Structure::Move { path, .. } => {
701695 let entry = identity_at(catalog, path)?;
......@@ -707,7 +701,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O
707701 }
708702 }
709703 Structure::Reorder { folder, .. } => {
710 let folder = folders(catalog)
704 let folder = folders(catalog, |_| true)
711705 .into_iter()
712706 .find(|listed| listed.path == *folder)?;
713707 Change::Place {
......@@ -733,7 +727,7 @@ fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::S
733727 let (structure, undo) = match change {
734728 Change::Rename { entry, from, to } => {
735729 let (_, path) = entry_of(catalog, *entry)?;
736 if name_of(&path) != from {
730 if entry_name(&path) != from {
737731 return None;
738732 }
739733 (
......@@ -1450,7 +1444,7 @@ mod tests {
14501444 }
14511445 notebook.create_group("", "Group").unwrap();
14521446 let order = |notebook: &Notebook, folder: &str| -> Vec<String> {
1453 let folder = folders(notebook.catalog())
1447 let folder = folders(notebook.catalog(), |_| true)
14541448 .into_iter()
14551449 .find(|listed| listed.path == folder)
14561450 .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 {
7373}
7474
7575impl Version {
76 fn parse(name: &str) -> Option<Self> {
76 pub(crate) fn parse(name: &str) -> Option<Self> {
7777 let (date, revision) = name.split_once("-r")?;
7878 let digits = |range: std::ops::Range<usize>| {
7979 date.get(range)
......@@ -120,6 +120,64 @@ pub fn describe_running() -> String {
120120struct Build {
121121 version: String,
122122 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 }
123181}
124182
125183#[derive(Clone, Debug, Deserialize)]
......@@ -169,25 +227,35 @@ fn newer(
169227}
170228
171229/// `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.
173231fn archive(
174232 key: &[u8],
175233 build: &[u8],
176234 signature: &[u8],
177235 version: &Version,
178236 platform: &str,
179) -> Result<Archive, String> {
237 running: Option<&Version>,
238) -> Result<(Archive, Vec<Change>), String> {
180239 verify(key, build, &String::from_utf8_lossy(signature))?;
181 let build: Build =
240 let mut build: Build =
182241 serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?;
183242 if Version::parse(&build.version).as_ref() != Some(version) {
184243 return Err(format!("build.json describes {}", build.version));
185244 }
186 build
245 let archive = build
187246 .archives
188 .get(platform)
189 .cloned()
190 .ok_or_else(|| format!("{} has no {platform} archive", version.name()))
247 .remove(platform)
248 .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?;
249 let mut changes: Vec<Change> = build
250 .changes
251 .into_iter()
252 .filter(|change| {
253 Version::parse(&change.version)
254 .is_some_and(|made| running.is_none_or(|running| made > *running))
255 })
256 .collect();
257 changes.sort_by_key(|change| change.kind);
258 Ok((archive, changes))
191259}
192260
193261fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> {
......@@ -302,10 +370,11 @@ pub enum Status {
302370 Idle,
303371 UpToDate,
304372 Downloading(Version),
305 /// Verified and unpacked beside the install, waiting for Restart to Update.
306 Ready(Version, PathBuf),
373 /// Verified and unpacked beside the install, waiting for Restart to Update, with what it
374 /// changes.
375 Ready(Version, PathBuf, Vec<Change>),
307376 /// Newer than this build, which can't install it itself: its folder has the download.
308 Available(Version),
377 Available(Version, Vec<Change>),
309378 Failed(&'static str),
310379}
311380
......@@ -326,6 +395,7 @@ fn check(
326395 progress: &dyn Fn(Status),
327396) -> Status {
328397 let platform = platform();
398 let since = running;
329399 // Under Rosetta the native build of the same version is still the update.
330400 let running = running.filter(|_| !translated());
331401 let fetch = |path: &str, limit| {
......@@ -346,15 +416,16 @@ fn check(
346416 };
347417 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;
348418 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)?;
350421 let Some(folder) = install.and_then(staging) else {
351 return Ok(Status::Available(version));
422 return Ok(Status::Available(version, changes));
352423 };
353424 let item = staged(&folder);
354425 if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name())
355426 && item.exists()
356427 {
357 return Ok(Status::Ready(version, item));
428 return Ok(Status::Ready(version, item, changes));
358429 }
359430 progress(Status::Downloading(version.clone()));
360431 let bytes = fetch(
......@@ -363,10 +434,10 @@ fn check(
363434 )?;
364435 check_archive(key, &archive, &bytes).map_err(unverified)?;
365436 Ok(match stage(&bytes, &folder, &version) {
366 Ok(item) => Status::Ready(version, item),
437 Ok(item) => Status::Ready(version, item, changes),
367438 Err(error) => {
368439 eprintln!("Cannot stage the update in {}: {error}", folder.display());
369 Status::Available(version)
440 Status::Available(version, changes)
370441 }
371442 })
372443 })();
......@@ -410,6 +481,8 @@ pub struct Updates {
410481 thread: std::thread::Thread,
411482 /// Restart to Update was chosen: swap the staged build in once the app quits.
412483 restart: bool,
484 /// The sync popup lists what the update changes.
485 pub(crate) changes_listed: bool,
413486}
414487
415488impl Updates {
......@@ -474,6 +547,7 @@ impl Updates {
474547 shared,
475548 thread,
476549 restart: false,
550 changes_listed: false,
477551 }
478552 }
479553
......@@ -499,7 +573,7 @@ impl Updates {
499573 /// The staged build to swap in, once Restart to Update has quit the app.
500574 pub fn restarting(&self) -> Option<PathBuf> {
501575 match self.status() {
502 Status::Ready(_, staged) if self.restart => Some(staged),
576 Status::Ready(_, staged, _) if self.restart => Some(staged),
503577 _ => None,
504578 }
505579 }
......@@ -518,20 +592,26 @@ impl State {
518592 return;
519593 };
520594 match status {
521 Status::Ready(version, _) => {
595 Status::Ready(version, _, changes) => {
522596 if platform::confirm(
523597 "Update ready",
524 &format!("Snowbound {version} is ready to install."),
598 &described(
599 format!("Snowbound {version} is ready to install."),
600 &changes,
601 ),
525602 "Later",
526603 "Restart to Update",
527604 ) {
528605 self.restart_to_update();
529606 }
530607 }
531 Status::Available(version) => {
608 Status::Available(version, changes) => {
532609 if platform::confirm(
533610 "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 ),
535615 "Later",
536616 "Open Build Folder",
537617 ) {
......@@ -548,6 +628,28 @@ impl State {
548628 }
549629}
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
551653/// Opens the folder a build is published in.
552654pub fn show_build(version: &Version) {
553655 platform::reveal(format!("{BASE}{}", version.folder()));
......@@ -696,6 +798,18 @@ mod tests {
696798 assert!(newer(b"<html>", "macos-aarch64", None).is_err());
697799 }
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
699813 /// A build.json for `archive`'s bytes under `platform`, with its signature.
700814 fn publish(
701815 pair: &Ed25519KeyPair,
......@@ -708,6 +822,7 @@ mod tests {
708822 let build = serde_json::to_vec_pretty(&serde_json::json!({
709823 "version": name,
710824 "commit": "0123456789abcdef",
825 "changes": changes(),
711826 "archives": {platform: {
712827 "file": file,
713828 "size": bytes.len(),
......@@ -728,35 +843,37 @@ mod tests {
728843 let bytes = b"an archive".to_vec();
729844 let (build, signature) =
730845 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();
732847 check_archive(key, &found, &bytes).unwrap();
733848
734849 let mut tampered = build.clone();
735850 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();
736851 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());
738853 assert!(
739854 archive(
740855 generate().public_key().as_ref(),
741856 &build,
742857 &signature,
743858 &tenth,
744 "linux-x86_64"
859 "linux-x86_64",
860 None
745861 )
746862 .is_err()
747863 );
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());
749865 assert!(
750866 archive(
751867 key,
752868 &build,
753869 &signature,
754870 &version("2026-09-29-r11"),
755 "linux-x86_64"
871 "linux-x86_64",
872 None
756873 )
757874 .is_err()
758875 );
759 assert!(archive(key, &build, &signature, &tenth, "macos-aarch64").is_err());
876 assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err());
760877
761878 assert!(
762879 check_archive(key, &found, b"an archivf")
......@@ -780,6 +897,98 @@ mod tests {
780897 );
781898 }
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
783992 /// An archive as `tools/release.py` packs this platform's, holding `marker`.
784993 fn pack(folder: &Path, marker: &str) -> Vec<u8> {
785994 if !cfg!(target_os = "macos") {
......@@ -856,10 +1065,14 @@ mod tests {
8561065 let status = check(&fetch, &key, Some(&running), Some(&install), &|status| {
8571066 progress.lock().unwrap().push(status)
8581067 });
859 let Status::Ready(found, item) = status else {
1068 let Status::Ready(found, item, listed) = status else {
8601069 panic!("{status:?}");
8611070 };
8621071 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 );
8631076 assert_eq!(
8641077 *progress.lock().unwrap(),
8651078 [Status::Downloading(found.clone())]
......@@ -869,7 +1082,10 @@ mod tests {
8691082
8701083 fetched.lock().unwrap().clear();
8711084 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 );
8731089 assert!(
8741090 !fetched
8751091 .lock()
......@@ -884,7 +1100,7 @@ mod tests {
8841100 );
8851101 assert_eq!(
8861102 check(&fetch, &key, Some(&running), None, &|_| {}),
887 Status::Available(found.clone())
1103 Status::Available(found.clone(), listed.clone())
8881104 );
8891105 assert_eq!(
8901106 check(
......@@ -955,6 +1171,13 @@ mod tests {
9551171 fn the_published_build_installs() {
9561172 let folder = scratch("published");
9571173 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();
9581181 let old = version("2000-01-01-r1");
9591182 let status = check(
9601183 &download,
......@@ -963,7 +1186,7 @@ mod tests {
9631186 Some(&install),
9641187 &|_| {},
9651188 );
966 let Status::Ready(found, staged) = status else {
1189 let Status::Ready(found, staged, _) = status else {
9671190 panic!("{status:?}");
9681191 };
9691192 apply(&staged, &install).unwrap();
crates/snowbound/src/windows.rs+103-15
......@@ -51,6 +51,8 @@ static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
5151static COMPOSED: AtomicBool = AtomicBool::new(false);
5252/// The window's scale factor, as `f32` bits, for hit-testing outside a frame.
5353static 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)];
5456/// The caption button under the pointer, by its hit-test code: as the system reports it
5557/// over Mica, where the system runs them, and as the row saw it for the close button,
5658/// whose glyph turns white on red.
......@@ -219,7 +221,7 @@ fn offer_to_open() {
219221 None,
220222 &format!("\"{executable}\" \"%1\""),
221223 );
222 for extension in [".one", ".onetoc2"] {
224 for extension in [".one", ".onetoc2", ".onepkg"] {
223225 set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), "");
224226 }
225227}
......@@ -259,6 +261,46 @@ fn reframe(hwnd: HWND) {
259261 unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) };
260262}
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
262304fn set_attribute<T>(hwnd: HWND, attribute: u32, value: &T) -> bool {
263305 unsafe {
264306 Dwm::DwmSetWindowAttribute(
......@@ -292,6 +334,14 @@ unsafe extern "system" fn frame_procedure(
292334 let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed));
293335 wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam)
294336 };
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 }
295345 match message {
296346 // Accent colours and transparency effects reach the window only as settings.
297347 wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => {
......@@ -652,23 +702,58 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
652702 })
653703}
654704
655/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons
656/// take white faces, as Internet Explorer's do there, so their text and icons stay legible,
657/// and the sidebar stays opaque.
658pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme {
659 let sidebar = theme.sidebar;
660 let theme = theme.over_backdrop();
661 if caption() != Caption::Glass || eleven() {
662 return theme;
705/// The theme over the window's material. Aero glass takes whatever lies behind the window
706/// and any colour the user picks, so on Windows 7 the toolbar keeps opaque faces under its
707/// icons, as `SNOWBOUND_W7_CHROME` picks: `bar`, Explorer's opaque command bar under the glass
708/// frame; `pills`, a face per group of tools over the glass; `frost`, the strip see-through
709/// enough to tint it; or `tint`, one panel of tools in the glass's colour, and `tint-tiles`,
710/// a tile per group.
711pub fn over_backdrop(theme: ui::Theme, appearance: Theme) -> ui::Theme {
712 if version() >= (6, 2, 0) {
713 return theme.over_backdrop();
663714 }
664 ui::Theme {
665 sidebar,
666 base: [1.0, 1.0, 1.0, 0.8],
667 tool: [1.0, 1.0, 1.0, 0.35],
668 ..theme
715 match std::env::var("SNOWBOUND_W7_CHROME").as_deref() {
716 Ok("pills") => ui::Theme {
717 strip: [0.0; 4],
718 tool: theme.base,
719 ..theme
720 },
721 Ok("frost") => ui::Theme {
722 strip: [theme.strip[0], theme.strip[1], theme.strip[2], 0.85],
723 ..theme
724 },
725 Ok(chrome @ ("tint" | "tint-tiles")) => ui::Theme {
726 strip: [0.0; 4],
727 tool: glass_tint(appearance == Theme::Dark),
728 tool_panel: chrome == "tint",
729 chip: [0.0, 0.0, 0.0, 0.35],
730 ..theme
731 },
732 _ => theme,
669733 }
670734}
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
672757/// None: the kit's own menus.
673758pub fn menu(_: Theme) -> Option<ui::Menu> {
674759 None
......@@ -1402,7 +1487,10 @@ pub fn edit_date(
14021487pub fn pick_notebook(title: &str) -> Option<PathBuf> {
14031488 pick(
14041489 title,
1405 &[("OneNote notebooks and sections", "*.onetoc2;*.one")],
1490 &[(
1491 "OneNote notebooks, sections and packages",
1492 "*.onetoc2;*.one;*.onepkg",
1493 )],
14061494 None,
14071495 false,
14081496 )
crates/snowbound/tests/replay.rs+98
......@@ -252,3 +252,101 @@ fn undo_walks_back_through_new_pages_and_their_titles() {
252252 assert_eq!(tabs, expected, "{tree}");
253253 }
254254}
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> + '_ {
316316/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
317317fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
318318 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) {
320325 let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1));
321 sum + nodes[index].gap * (count.max(1) - 1) as f32
326 sum + node.gap * (count.max(1) - 1) as f32
322327 } else {
323328 sizes.fold(0.0, f32::max)
324329 }
......@@ -335,13 +340,7 @@ pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 {
335340 !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none()
336341 })
337342 .map(|child| least(nodes, *child));
338 let content = if along(node, 0) {
339 let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1));
340 sum + node.gap * (count.max(1) - 1) as f32
341 } else {
342 sizes.fold(0.0, f32::max)
343 };
344 content + 2.0 * node.pad[0]
343 span(node, 0, sizes) + 2.0 * node.pad[0]
345344}
346345
347346/// The least width `index` takes without clipping its children.
crates/ui/src/lib.rs+23-39
......@@ -324,6 +324,15 @@ struct Motion {
324324 opacity: f32,
325325}
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
327336/// What a box is this frame. Colours are linear RGBA.
328337#[derive(Clone, Default)]
329338pub struct Spec<'a> {
......@@ -332,13 +341,13 @@ pub struct Spec<'a> {
332341 /// The axis its children flow along.
333342 pub axis: Axis,
334343 pub text: Option<&'a str>,
335 /// The family the label is shaped in, where it has the glyphs; the interface's otherwise.
336344 /// The label's size in logical pixels; the theme's otherwise.
337345 pub font_size: Option<f32>,
338346 /// Shapes the label semibold.
339347 pub bold: bool,
340348 /// Shapes the label italic.
341349 pub italic: bool,
350 /// The family the label is shaped in, where it has the glyphs; the interface's otherwise.
342351 pub font: Option<&'a str>,
343352 pub overflow: Overflow,
344353 /// The label's colour; the theme's text colour otherwise.
......@@ -463,12 +472,7 @@ impl Primitives<'_> {
463472 pivot,
464473 tilt,
465474 opacity,
466 } = self.motion.unwrap_or(Motion {
467 zoom: 1.0,
468 pivot: [0.0; 2],
469 tilt: 0.0,
470 opacity: 1.0,
471 });
475 } = self.motion.unwrap_or(Motion::NONE);
472476 let device = |point: [f32; 2]| point.map(|value| value * scale);
473477 let moved = |point: [f32; 2]| {
474478 device(std::array::from_fn(|axis| {
......@@ -797,8 +801,11 @@ impl Ui {
797801 }
798802
799803 /// Asks for a frame `after` this one, as a running clock does.
800 pub fn wake_after(&mut self, after: std::time::Duration) {
801 let due = self.now + after;
804 pub fn wake_after(&mut self, after: Duration) {
805 self.wake_by(self.now + after);
806 }
807
808 fn wake_by(&mut self, due: Instant) {
802809 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
803810 }
804811
......@@ -1196,7 +1203,7 @@ impl Ui {
11961203
11971204 /// The id a box built next under the current parent would take.
11981205 pub fn id(&self, part: impl Hash) -> Id {
1199 self.nodes[*self.stack.last().unwrap()].id.child(part)
1206 self.current().child(part)
12001207 }
12011208
12021209 /// Adds a box and makes it the parent of the boxes built until `close`.
......@@ -1247,7 +1254,6 @@ impl Ui {
12471254 self.signal(id)
12481255 }
12491256
1250 /// Eases towards `target` over the next frames, starting there the first time `id` asks.
12511257 /// Holds animated value `id` at `value` without easing, as a dragged box follows the
12521258 /// pointer; it eases from there once animated again.
12531259 pub fn hold(&mut self, id: Id, value: f32) -> f32 {
......@@ -1257,6 +1263,7 @@ impl Ui {
12571263 value
12581264 }
12591265
1266 /// Eases towards `target` over the next frames, starting there the first time `id` asks.
12601267 pub fn animate(&mut self, id: Id, target: f32) -> f32 {
12611268 let state = self.states.entry(id).or_default();
12621269 state.touched = self.frame;
......@@ -1293,7 +1300,7 @@ impl Ui {
12931300 /// The size of `text` as a label, in logical pixels.
12941301 pub fn measure(&mut self, text: &str) -> [f32; 2] {
12951302 self.texts
1296 .label(text, self.theme.font_size, false, None, self.frame)
1303 .label(text, self.theme.font_size, self.frame)
12971304 .size
12981305 }
12991306
......@@ -1316,8 +1323,7 @@ impl Ui {
13161323 self.caret = Some((id, start, self.frame));
13171324 let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start));
13181325 if let Some(hold) = hold {
1319 let due = self.now + hold;
1320 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
1326 self.wake_after(hold);
13211327 }
13221328 opacity
13231329 }
......@@ -1468,10 +1474,8 @@ impl Ui {
14681474 {
14691475 let opacity = self.progress(due, TIP_FADE);
14701476 self.nodes[index].motion = Some(Motion {
1471 zoom: 1.0,
1472 pivot: [0.0; 2],
1473 tilt: 0.0,
14741477 opacity,
1478 ..Motion::NONE
14751479 });
14761480 self.paint(index, None, None);
14771481 }
......@@ -1961,28 +1965,8 @@ impl Ui {
19611965 layers
19621966 }
19631967
1964 pub(crate) fn texts(&mut self) -> (&mut Texts, u64) {
1965 (&mut self.texts, self.frame)
1966 }
1967
1968 pub(crate) fn field(
1969 &mut self,
1970 id: Id,
1971 ) -> (
1972 &mut Selection,
1973 &mut (Selection, SelectionUnit),
1974 &mut Option<[usize; 2]>,
1975 ) {
1976 let state = self.states.entry(id).or_default();
1977 (&mut state.selection, &mut state.press, &mut state.select)
1978 }
1979
1980 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
1981 &mut self.states.entry(id).or_default().grab
1982 }
1983
1984 pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> {
1985 &mut self.states.entry(id).or_default().held
1968 pub(crate) fn state(&mut self, id: Id) -> &mut State {
1969 self.states.entry(id).or_default()
19861970 }
19871971}
19881972
crates/ui/src/popup.rs+55-96
......@@ -86,6 +86,13 @@ impl Item<'_> {
8686 }
8787}
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
8996/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
9097/// filter field showing `filter` while empty when given. Returns the index of the item
9198/// chosen.
......@@ -100,11 +107,7 @@ pub fn menu(
100107 return None;
101108 }
102109 let style = ui.theme.menu();
103 let mut measure = |text| {
104 ui.texts
105 .label(text, style.font_size, false, None, ui.frame)
106 .size[0]
107 };
110 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];
108111 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
109112 [
110113 text.max(
......@@ -118,15 +121,12 @@ pub fn menu(
118121 shortcut.max(measure(item.trailing())),
119122 ]
120123 });
121 let icons = items
122 .iter()
123 .any(|item| item.icon.is_some() || item.checked == Some(true));
124124 let width =
125125 text + if shortcut > 0.0 {
126126 3.0 * ICON_GAP + shortcut
127127 } else {
128128 0.0
129 } + if icons { ICON + ICON_GAP } else { 0.0 }
129 } + if icons(items) { ICON + ICON_GAP } else { 0.0 }
130130 + if items.len() as f32 > ROWS {
131131 GUTTER
132132 } else {
......@@ -189,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) ->
189189 return;
190190 };
191191 if now < due {
192 ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due)));
192 ui.wake_by(due);
193193 return;
194194 }
195195 popup.submenu = Some((key, None));
......@@ -283,7 +283,7 @@ fn tooltip_below(
283283 return;
284284 };
285285 if now < due {
286 ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due)));
286 ui.wake_by(due);
287287 return;
288288 }
289289 let title = if keys.is_empty() {
......@@ -723,34 +723,8 @@ pub fn colors(
723723 // The button is cell 0 and the swatches follow it.
724724 let cell = |index: usize| id.child(("cell", index));
725725 let count = swatches.len() + 1;
726 let keys = navigation(
727 ui,
728 &[id],
729 &[
730 NamedKey::ArrowLeft,
731 NamedKey::ArrowRight,
732 NamedKey::ArrowUp,
733 NamedKey::ArrowDown,
734 NamedKey::Enter,
735 ],
736 );
737 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
738 let mut chosen = None;
739 for index in 0..count {
740 let signal = ui.signal(cell(index));
741 if signal.hovered && ui.moved {
742 highlight = Some(index);
743 }
744 if signal.clicked {
745 chosen = Some(index);
746 }
747 }
748 for key in keys {
749 highlight = Some(match (key, highlight) {
750 (NamedKey::Enter, _) => {
751 chosen = chosen.or(highlight);
752 continue;
753 }
726 let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| {
727 match (key, highlight) {
754728 (_, None) => 0,
755729 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),
756730 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),
......@@ -758,8 +732,8 @@ pub fn colors(
758732 (_, Some(0)) => 1,
759733 (_, Some(at)) if at + columns < count => at + columns,
760734 (_, Some(at)) => at,
761 });
762 }
735 }
736 });
763737 if let Some(index) = chosen {
764738 ui.close_popup(id);
765739 return Some((index > 0).then(|| swatches[index - 1].0));
......@@ -834,7 +808,6 @@ pub fn colors(
834808 }
835809 highlighted(ui, id, highlight.map(cell));
836810 ui.close();
837 state(ui, id).highlight = highlight.map(|cell| cell as u64);
838811 None
839812}
840813
......@@ -845,14 +818,15 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {
845818 }
846819}
847820
848/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
849/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
850/// columns and rows chosen.
851pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> {
852 if !ui.popup_open(id) {
853 return None;
854 }
855 let cell = |index: usize| id.child(("cell", index));
821/// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight,
822/// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose.
823fn pick_cell(
824 ui: &mut Ui,
825 id: Id,
826 count: usize,
827 cell: impl Fn(usize) -> Id,
828 step: impl Fn(NamedKey, Option<usize>) -> usize,
829) -> (Option<usize>, Option<usize>) {
856830 let keys = navigation(
857831 ui,
858832 &[id],
......@@ -864,10 +838,9 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
864838 NamedKey::Enter,
865839 ],
866840 );
867 let [columns, rows] = size;
868841 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
869842 let mut chosen = None;
870 for index in 0..columns * rows {
843 for index in 0..count {
871844 let signal = ui.signal(cell(index));
872845 if signal.hovered && ui.moved {
873846 highlight = Some(index);
......@@ -877,20 +850,36 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
877850 }
878851 }
879852 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 };
881875 let [column, row] = [at % columns, at / columns];
882 highlight = Some(match key {
883 NamedKey::Enter => {
884 chosen = chosen.or(highlight);
885 continue;
886 }
887 _ if highlight.is_none() => 0,
876 match key {
888877 NamedKey::ArrowLeft => at - usize::from(column > 0),
889878 NamedKey::ArrowRight => at + usize::from(column + 1 < columns),
890879 NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 },
891880 _ => at + if row + 1 < rows { columns } else { 0 },
892 });
893 }
881 }
882 });
894883 let extent = |index: usize| [index % columns + 1, index / columns + 1];
895884 if let Some(index) = chosen {
896885 ui.close_popup(id);
......@@ -971,7 +960,6 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
971960 ui.close();
972961 highlighted(ui, id, highlight.map(cell));
973962 ui.close();
974 state(ui, id).highlight = highlight.map(|cell| cell as u64);
975963 None
976964}
977965
......@@ -1011,41 +999,15 @@ pub fn gallery(
1011999 })
10121000 .map_or(1, |group| group.columns)
10131001 };
1014 let keys = navigation(
1015 ui,
1016 &[id],
1017 &[
1018 NamedKey::ArrowLeft,
1019 NamedKey::ArrowRight,
1020 NamedKey::ArrowUp,
1021 NamedKey::ArrowDown,
1022 NamedKey::Enter,
1023 ],
1024 );
1025 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
1026 let mut chosen = None;
1027 for index in 0..count {
1028 let signal = ui.signal(cell_id(index));
1029 if signal.hovered && ui.moved {
1030 highlight = Some(index);
1031 }
1032 if signal.clicked {
1033 chosen = Some(index);
1034 }
1035 }
1036 for key in keys {
1002 let (highlight, chosen) = pick_cell(ui, id, count, cell_id, |key, highlight| {
10371003 let at = highlight.or(current.first().copied()).unwrap_or(0);
1038 highlight = Some(match key {
1039 NamedKey::Enter => {
1040 chosen = chosen.or(highlight);
1041 continue;
1042 }
1004 match key {
10431005 NamedKey::ArrowLeft => at.saturating_sub(1),
10441006 NamedKey::ArrowRight => (at + 1).min(count - 1),
10451007 NamedKey::ArrowUp => at.saturating_sub(columns(at)),
10461008 _ => (at + columns(at)).min(count - 1),
1047 });
1048 }
1009 }
1010 });
10491011 if let Some(index) = chosen {
10501012 ui.close_popup(id);
10511013 return Some(index);
......@@ -1113,7 +1075,6 @@ pub fn gallery(
11131075 }
11141076 highlighted(ui, id, highlight.map(cell_id));
11151077 ui.close();
1116 state(ui, id).highlight = highlight.map(|cell| cell as u64);
11171078 None
11181079}
11191080
......@@ -1488,9 +1449,7 @@ impl<'a> Matches<'a> {
14881449 rules,
14891450 ruled,
14901451 rule,
1491 icons: items
1492 .iter()
1493 .any(|item| item.icon.is_some() || item.checked == Some(true)),
1452 icons: icons(items),
14941453 }
14951454 }
14961455}
crates/ui/src/shell.rs+15-9
......@@ -34,12 +34,12 @@ const EDGE: f32 = 20.0;
3434/// The row is as wide as the tabs, and gives up all of that where its own row lacks room.
3535/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the
3636/// 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.
3838#[allow(clippy::too_many_arguments)]
3939pub fn section_tabs(
4040 ui: &mut Ui,
4141 row: Id,
42 tabs: &[(&str, [f32; 4])],
42 tabs: &[(&str, [f32; 4], bool)],
4343 active: usize,
4444 lit: Option<usize>,
4545 dragged: Option<Dragged>,
......@@ -52,8 +52,11 @@ pub fn section_tabs(
5252 let mut left = crate::SHADOW[0];
5353 let placed: Vec<_> = tabs
5454 .iter()
55 .map(|(name, _)| {
56 let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height);
55 .map(|&(name, _, bold)| {
56 let label = ui
57 .texts
58 .styled(name, ui.theme.font_size, bold, false, None, ui.frame);
59 let width = TAB_PAD + label.size[0] + 4.0 + lean(height);
5760 left += width;
5861 (left - width, width)
5962 })
......@@ -129,7 +132,7 @@ pub fn section_tabs(
129132 .chain((active < tabs.len() && Some(active) != lifted).then_some(active))
130133 .chain(lifted);
131134 for index in order {
132 let (name, color) = tabs[index];
135 let (name, color, bold) = tabs[index];
133136 let (x, width) = placed[index];
134137 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
135138 let key = ui.id(("x", name));
......@@ -164,6 +167,7 @@ pub fn section_tabs(
164167 size: [px(width), px(tall)],
165168 position: [x - offset, height - tall],
166169 text: Some(name),
170 bold,
167171 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
168172 fill: Some(if lit == Some(index) {
169173 mix(colors.frame[0], [1.0; 4], 0.08)
......@@ -306,6 +310,11 @@ fn opens(ui: &mut Ui, id: Id, menu: Id) {
306310 }
307311}
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
309318/// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon`
310319/// tinted by `tint` shows faded, and it takes no clicks.
311320pub fn unavailable(
......@@ -315,7 +324,6 @@ pub fn unavailable(
315324 tint: [f32; 4],
316325 menu: bool,
317326) {
318 let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35];
319327 let arrow = faded(ui.theme.text_dim);
320328 let id = ui.open(
321329 part,
......@@ -530,9 +538,7 @@ pub fn combo(
530538 enabled: bool,
531539) {
532540 let theme = ui.theme.clone();
533 let faded = |[red, green, blue, alpha]: [f32; 4]| {
534 [red, green, blue, if enabled { alpha } else { alpha * 0.35 }]
535 };
541 let faded = |color| if enabled { color } else { faded(color) };
536542 let id = ui.open(
537543 part,
538544 Spec {
crates/ui/src/tests.rs+3-3
......@@ -605,8 +605,8 @@ fn focused_field(text: &str) -> (Ui, String) {
605605/// Moves the pointer over the caret before byte `index` of the field's `text`.
606606fn point_to(ui: &mut Ui, text: &str, index: usize) {
607607 let size = ui.theme.font_size;
608 let (texts, frame) = ui.texts();
609 let layout = &texts.label(text, size, false, None, frame).layout;
608 let (texts, frame) = (&mut ui.texts, ui.frame);
609 let layout = &texts.label(text, size, frame).layout;
610610 let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream)
611611 .geometry(layout, 1.0)
612612 .x0 as f32;
......@@ -2817,7 +2817,7 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id {
28172817 ];
28182818 let tabs: Vec<_> = names
28192819 .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))
28212821 .collect();
28222822 let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]);
28232823 let row = Id::ROOT.child("tabs");
crates/ui/src/text.rs+4-11
......@@ -24,19 +24,12 @@ pub(crate) struct Texts {
2424}
2525
2626impl Texts {
27 /// `size` is in logical pixels; `font` names a family to prefer over the interface's.
28 pub fn label(
29 &mut self,
30 text: &str,
31 size: f32,
32 bold: bool,
33 font: Option<&str>,
34 frame: u64,
35 ) -> Rc<Label> {
36 self.styled(text, size, bold, false, font, frame)
27 /// `text` in the interface's regular face, `size` logical pixels.
28 pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> {
29 self.styled(text, size, false, false, None, frame)
3730 }
3831
39 /// `label`, slanted where `italic`.
32 /// `font` names a family to prefer over the interface's.
4033 pub fn styled(
4134 &mut self,
4235 text: &str,
crates/ui/src/theme.rs+7-1
......@@ -13,8 +13,12 @@ pub struct Theme {
1313 pub strip: [f32; 4],
1414 /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop.
1515 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.
1718 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,
1822 pub accent: [f32; 4],
1923 /// The text caret, and selected text's fill with and without keyboard focus; the
2024 /// platform's own where it has them.
......@@ -145,6 +149,7 @@ impl Theme {
145149 strip: srgb(0x27, 0x2a, 0x2b),
146150 sidebar: srgb(0x27, 0x2a, 0x2b),
147151 tool: [0.0; 4],
152 tool_panel: false,
148153 accent: srgb(0x00, 0x79, 0xa6),
149154 caret,
150155 selection,
......@@ -176,6 +181,7 @@ impl Theme {
176181 strip: srgb(0xeb, 0xed, 0xf0),
177182 sidebar: srgb(0xeb, 0xed, 0xf0),
178183 tool: [0.0; 4],
184 tool_panel: false,
179185 accent: srgb(0x00, 0x79, 0xa6),
180186 caret,
181187 selection,
crates/ui/src/widgets.rs+18-22
......@@ -162,11 +162,11 @@ pub fn scrollbar(
162162 if signal.pressed
163163 && let Some(pointer) = pointer
164164 {
165 *ui.grab(id) = pointer - start;
165 ui.state(id).grab = pointer - start;
166166 }
167167 let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| {
168168 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);
170170 range[0] + fraction * span
171171 });
172172 let mut position = [0.0; 2];
......@@ -212,7 +212,7 @@ fn system_scrollbar(
212212 let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]);
213213 let now = ui.now;
214214 if !signal.pressed && !signal.dragging {
215 *ui.held(id) = None;
215 ui.state(id).held = None;
216216 }
217217 let request = Scroller {
218218 axis,
......@@ -220,7 +220,7 @@ fn system_scrollbar(
220220 scale: ui.scale,
221221 value: ((offset - range[0]) / span).clamp(0.0, 1.0),
222222 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),
224224 active: ui.window_focused,
225225 };
226226 let painted = (ui.scrollers.as_mut()?.paint)(&request);
......@@ -239,15 +239,15 @@ fn system_scrollbar(
239239 ]
240240 .into_iter()
241241 .find(|(_, extent)| within(*extent, at));
242 *ui.held(id) = hit.map(|(part, _)| (part, now));
243 *ui.grab(id) = at - painted.knob[0];
242 ui.state(id).held = hit.map(|(part, _)| (part, now));
243 ui.state(id).grab = at - painted.knob[0];
244244 }
245 match *ui.held(id) {
245 match ui.state(id).held {
246246 Some((ScrollerPart::Knob, _)) => {
247247 let travel =
248248 (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]);
249 let fraction = ((at - *ui.grab(id) - painted.slot[0]) / travel.max(f32::EPSILON))
250 .clamp(0.0, 1.0);
249 let from = at - ui.state(id).grab - painted.slot[0];
250 let fraction = (from / travel.max(f32::EPSILON)).clamp(0.0, 1.0);
251251 chosen = Some(range[0] + fraction * span);
252252 }
253253 Some((part, due)) if now >= due => {
......@@ -260,7 +260,7 @@ fn system_scrollbar(
260260 _ => view - line,
261261 };
262262 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));
264264 chosen = Some((offset + step).clamp(range[0], range[1]));
265265 }
266266 _ => {}
......@@ -360,13 +360,11 @@ fn field(
360360 .zip(ui.rect(id))
361361 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)
362362 .filter(|_| signal.pressed || signal.dragging);
363 let (mut selection, mut press, select) = {
364 let (selection, press, select) = ui.field(id);
365 (*selection, *press, select.take())
366 };
363 let state = ui.state(id);
364 let (mut selection, mut press, select) = (state.selection, state.press, state.select.take());
367365 let before = [selection.anchor(), selection.focus()];
368 let (texts, frame) = ui.texts();
369 let mut label = texts.label(&shown_text, size, false, None, frame);
366 let (texts, frame) = (&mut ui.texts, ui.frame);
367 let mut label = texts.label(&shown_text, size, frame);
370368 if let Some(x) = pointer {
371369 let layout = &label.layout;
372370 let unit = if signal.pressed { signal.unit } else { press.1 };
......@@ -470,7 +468,7 @@ fn field(
470468 let range = to_text(text, range.start)..to_text(text, range.end);
471469 text.replace_range(range.clone(), inserted);
472470 shown_text = masked(text);
473 label = texts.label(&shown_text, size, false, None, frame);
471 label = texts.label(&shown_text, size, frame);
474472 selection = Selection::from_byte_index(
475473 &label.layout,
476474 to_shown(text, range.start + inserted.len()),
......@@ -478,11 +476,9 @@ fn field(
478476 );
479477 }
480478 let moved = signal.pressed || before != [selection.anchor(), selection.focus()];
481 {
482 let (stored, stored_press, _) = ui.field(id);
483 *stored = selection;
484 *stored_press = press;
485 }
479 let state = ui.state(id);
480 state.selection = selection;
481 state.press = press;
486482 let theme = ui.theme.clone();
487483 let shown = if text.is_empty() {
488484 placeholder
fuzz/Cargo.lock+240
......@@ -2,6 +2,22 @@
22# It is not intended for manual editing.
33version = 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
521[[package]]
622name = "aes"
723version = "0.9.3"
......@@ -14,6 +30,20 @@ dependencies = [
1430 "zeroize",
1531]
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
1747[[package]]
1848name = "arbitrary"
1949version = "1.4.2"
......@@ -77,6 +107,24 @@ dependencies = [
77107 "syn 3.0.5",
78108]
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
80128[[package]]
81129name = "canvas"
82130version = "0.1.0"
......@@ -135,6 +183,12 @@ dependencies = [
135183 "zeroize",
136184]
137185
186[[package]]
187name = "cmov"
188version = "0.5.4"
189source = "registry+https://github.com/rust-lang/crates.io-index"
190checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
191
138192[[package]]
139193name = "const-oid"
140194version = "0.10.2"
......@@ -156,15 +210,50 @@ dependencies = [
156210 "libc",
157211]
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
159222[[package]]
160223name = "crypto-common"
161224version = "0.2.2"
162225source = "registry+https://github.com/rust-lang/crates.io-index"
163226checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
164227dependencies = [
228 "getrandom",
165229 "hybrid-array",
230 "rand_core",
166231]
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
168257[[package]]
169258name = "digest"
170259version = "0.11.3"
......@@ -174,6 +263,7 @@ dependencies = [
174263 "block-buffer",
175264 "const-oid",
176265 "crypto-common",
266 "ctutils",
177267 "zeroize",
178268]
179269
......@@ -232,12 +322,32 @@ version = "2.5.0"
232322source = "registry+https://github.com/rust-lang/crates.io-index"
233323checksum = "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
235335[[package]]
236336name = "find-msvc-tools"
237337version = "0.1.12"
238338source = "registry+https://github.com/rust-lang/crates.io-index"
239339checksum = "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
241351[[package]]
242352name = "foldhash"
243353version = "0.2.0"
......@@ -283,6 +393,16 @@ dependencies = [
283393 "cfg-if",
284394 "libc",
285395 "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",
286406]
287407
288408[[package]]
......@@ -324,6 +444,15 @@ dependencies = [
324444 "hashbrown 0.17.1",
325445]
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
327456[[package]]
328457name = "hybrid-array"
329458version = "0.4.14"
......@@ -556,6 +685,12 @@ version = "0.8.3"
556685source = "registry+https://github.com/rust-lang/crates.io-index"
557686checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
558687
688[[package]]
689name = "lzxd"
690version = "0.2.7"
691source = "registry+https://github.com/rust-lang/crates.io-index"
692checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168"
693
559694[[package]]
560695name = "md5"
561696version = "0.8.1"
......@@ -577,6 +712,16 @@ dependencies = [
577712 "libc",
578713]
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
580725[[package]]
581726name = "nix"
582727version = "0.31.3"
......@@ -593,6 +738,12 @@ dependencies = [
593738name = "notebook"
594739version = "0.1.0"
595740dependencies = [
741 "aes-gcm",
742 "base64",
743 "cab",
744 "getrandom",
745 "hmac",
746 "nix",
596747 "onestore",
597748 "rusqlite",
598749 "serde",
......@@ -600,8 +751,16 @@ dependencies = [
600751 "sha2",
601752 "tempfile",
602753 "thiserror",
754 "windows-sys",
755 "zeroize",
603756]
604757
758[[package]]
759name = "num-conv"
760version = "0.2.2"
761source = "registry+https://github.com/rust-lang/crates.io-index"
762checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
763
605764[[package]]
606765name = "objc2"
607766version = "0.6.4"
......@@ -660,6 +819,7 @@ dependencies = [
660819 "base64",
661820 "bumpalo",
662821 "cbc",
822 "file-guard",
663823 "getrandom",
664824 "md5",
665825 "nix",
......@@ -735,6 +895,17 @@ version = "0.3.34"
735895source = "registry+https://github.com/rust-lang/crates.io-index"
736896checksum = "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
738909[[package]]
739910name = "potential_utf"
740911version = "0.1.6"
......@@ -746,6 +917,12 @@ dependencies = [
746917 "zerovec",
747918]
748919
920[[package]]
921name = "powerfmt"
922version = "0.2.0"
923source = "registry+https://github.com/rust-lang/crates.io-index"
924checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
925
749926[[package]]
750927name = "proc-macro2"
751928version = "1.0.107"
......@@ -770,6 +947,12 @@ version = "6.0.0"
770947source = "registry+https://github.com/rust-lang/crates.io-index"
771948checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
772949
950[[package]]
951name = "rand_core"
952version = "0.10.1"
953source = "registry+https://github.com/rust-lang/crates.io-index"
954checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
955
773956[[package]]
774957name = "read-fonts"
775958version = "0.41.0"
......@@ -905,6 +1088,12 @@ version = "2.0.1"
9051088source = "registry+https://github.com/rust-lang/crates.io-index"
9061089checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
9071090
1091[[package]]
1092name = "simd-adler32"
1093version = "0.3.10"
1094source = "registry+https://github.com/rust-lang/crates.io-index"
1095checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
1096
9081097[[package]]
9091098name = "skrifa"
9101099version = "0.44.0"
......@@ -1011,6 +1200,25 @@ dependencies = [
10111200 "syn 3.0.5",
10121201]
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
10141222[[package]]
10151223name = "tinystr"
10161224version = "0.8.4"
......@@ -1034,6 +1242,16 @@ version = "1.0.24"
10341242source = "registry+https://github.com/rust-lang/crates.io-index"
10351243checksum = "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
10371255[[package]]
10381256name = "utf16_iter"
10391257version = "1.0.5"
......@@ -1097,6 +1315,28 @@ dependencies = [
10971315 "unicode-ident",
10981316]
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
11001340[[package]]
11011341name = "windows"
11021342version = "0.62.2"
fuzz/Cargo.toml+1-1
......@@ -9,7 +9,7 @@ cargo-fuzz = true
99
1010[dependencies]
1111libfuzzer-sys = "0.4"
12onestore = { path = "../crates/onestore", features = ["protected"] }
12onestore = { path = "../crates/onestore" }
1313notebook = { path = "../crates/notebook" }
1414canvas = { path = "../crates/canvas" }
1515draw = { path = "../crates/draw", default-features = false }
fuzz/fuzz_targets/canvas_editor.rs+18-30
......@@ -209,37 +209,25 @@ fuzz_target!(|input: &[u8]| {
209209 .into_iter()
210210 .enumerate()
211211 {
212 objects.push(onestore::page::PageObject::Image(onestore::page::Image {
213 id: onestore::ExGuid {
214 n: index.try_into().unwrap(),
215 ..Default::default()
216 },
217 layout: onestore::document::Layout {
218 x: Some(x),
219 y: Some(position[1] + y),
220 max_width: Some(72.0),
221 max_height: Some(40.0),
222 ..Default::default()
223 },
224 bytes: Some(std::sync::Arc::from(&b"deferred decoding"[..])),
225 background,
226 alt: None,
227 size: None,
228 }));
212 let mut image =
213 canvas::editor::picture(b"deferred decoding".to_vec(), [72.0, 40.0]).unwrap();
214 image.id = onestore::ExGuid {
215 n: index.try_into().unwrap(),
216 ..Default::default()
217 };
218 [image.layout.x, image.layout.y] = [Some(x), Some(position[1] + y)];
219 image.background = background;
220 objects.push(onestore::page::PageObject::Image(image));
229221 }
230 CanvasEditor::from_page(
231 onestore::page::Page {
232 title: String::new(),
233 identity: None,
234 created: date.map(|date| date.timestamp()),
235 margin_origin: [0.0; 2],
236 color: None,
237 definitions: BTreeMap::new(),
238 objects,
239 },
240 engine,
241 )
242 .unwrap()
222 // An empty editor's page is a page with every property at its default.
223 let blank = TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]);
224 let mut page = CanvasEditor::new(engine, blank.unwrap(), 468.0)
225 .unwrap()
226 .page()
227 .unwrap();
228 page.created = date.map(|date| date.timestamp());
229 page.objects = objects;
230 CanvasEditor::from_page(page, engine).unwrap()
243231 } else {
244232 CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), date).unwrap()
245233 };
fuzz/fuzz_targets/offline_model.rs+3
......@@ -1,6 +1,9 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
33
4#[path = "../../crates/onestore/tests/support/ops.rs"]
5mod ops;
6
47#[path = "../../crates/notebook/tests/support/model_ops.rs"]
58mod model_ops;
69#[path = "../../crates/notebook/tests/support/server.rs"]
fuzz/fuzz_targets/offline_page.rs+3
......@@ -1,6 +1,9 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
33
4#[path = "../../crates/onestore/tests/support/ops.rs"]
5mod ops;
6
47#[path = "../../crates/onestore/tests/support/disk.rs"]
58mod disk;
69#[path = "../../crates/notebook/tests/support/model_ops.rs"]
fuzz/fuzz_targets/protected.rs+15-5
......@@ -1,12 +1,14 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
33use onestore::{
4 Reference, RevisionIndex, Store,
5 protected::{Limits, UnlockedSection},
4 Arena, Reference, RevisionIndex, Section, Store,
5 protected::{Key, Limits, UnlockedSection},
66};
77use 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(|| {
1012 [
1113 (&include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one")[..], include_str!("../../corpus/native-encrypted/manifest.json")),
1214 (&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::
2123 }).collect();
2224 ranges.sort_by_key(|range| (range.start, range.end));
2325 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)
2529 }).collect()
2630});
2731
......@@ -29,7 +33,7 @@ fuzz_target!(|data: &[u8]| {
2933 if data.len() < 8 {
3034 return;
3135 }
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()];
3337 let mut bytes = source.clone();
3438 let mut password = password.clone();
3539 let mode = data[1] % 3;
......@@ -46,6 +50,12 @@ fuzz_target!(|data: &[u8]| {
4650 bytes[range.start + offset..range.start + offset + count]
4751 .copy_from_slice(&data[8..8 + count]);
4852 }
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 }
4959 let Ok(store) = Store::parse(&bytes) else {
5060 return;
5161 };
fuzz/fuzz_targets/protected_write.rs+35-38
......@@ -1,39 +1,33 @@
11#![no_main]
2//! Chains text edits on a protected section: every written image unlocks with the same
3//! password, reads back as the model predicts and stores none of the new text in clear.
4//! Text typed at the end of a text whose empty final run keeps an insertion style takes
5//! that style, which the page model cannot show, so only its characters are predicted.
2//! Chains text edits on a protected section kept open under its key, now and then writing
3//! it anew under another password: every sealed image opens with the key, reads back as the
4//! model predicts and stores none of the new text in clear. Text typed at the end of a text
5//! whose empty final run keeps an insertion style takes that style, which the page model
6//! cannot show, so only its characters are predicted.
67use libfuzzer_sys::fuzz_target;
78use onestore::{
8 ExGuid, RevisionIndex, Store,
9 document::Kind,
9 Arena, ExGuid, Section,
1010 op::{Edit, Op, PageOp, predict},
1111 page::{Page, PageObject},
12 protected::{Limits, UnlockedSection},
12 protected::{Key, rekey},
1313};
14use std::sync::LazyLock;
1415
1516const SOURCE: &[u8] =
1617 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.
20fn page(bytes: &[u8]) -> (ExGuid, Page, Vec<ExGuid>) {
21 let store = Store::parse(bytes).unwrap();
22 let index = RevisionIndex::parse(&store).unwrap();
23 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
24 let document = unlocked.document().unwrap();
25 let (space, _) = document.pages().unwrap()[0];
26 let view = &document.spaces[&space];
27 let hidden = view.revisions[&view.contexts[&ExGuid::default()]]
28 .nodes
29 .iter()
30 .filter(|(_, node)| {
31 matches!(&node.kind, Kind::RichText { text, runs, .. }
32 if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end))
33 })
34 .map(|(id, _)| *id)
35 .collect();
36 (space, Page::from_space(&document, space).unwrap(), hidden)
25/// The first page.
26fn page(bytes: &[u8], key: &Key) -> (ExGuid, Page) {
27 let arena = Arena::default();
28 let mut section = Section::unlock(&arena, bytes.to_vec(), key).unwrap();
29 let (space, ..) = section.pages().unwrap()[0];
30 (space, section.page(space).unwrap())
3731}
3832
3933fn texts(page: &Page) -> Vec<String> {
......@@ -50,11 +44,20 @@ fn texts(page: &Page) -> Vec<String> {
5044
5145fuzz_target!(|data: &[u8]| {
5246 let mut bytes = SOURCE.to_vec();
47 let mut key = &KEYS[0];
5348 for (at, step) in (0..).zip(data.chunks(12).take(4)) {
5449 if step.len() < 4 {
5550 return;
5651 }
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);
5861 let candidates: Vec<_> = expected
5962 .objects
6063 .iter()
......@@ -74,7 +77,6 @@ fuzz_target!(|data: &[u8]| {
7477 "\u{1f512}sealed\u{1f512}{}",
7578 String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "")
7679 );
77 let styled = hidden.contains(&text.id) && stop == end;
7880 let op = PageOp::Text {
7981 text: text.id,
8082 range: start..stop,
......@@ -87,10 +89,10 @@ fuzz_target!(|data: &[u8]| {
8789 at: 133_000_000_000_000_000 + at * 10_000_000,
8890 ops: vec![Op::Page { space, op }],
8991 };
90 let store = Store::parse(&bytes).unwrap();
91 let index = RevisionIndex::parse(&store).unwrap();
92 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
93 let transaction = unlocked.apply("Fuzz", &edit).unwrap();
92 let arena = Arena::default();
93 let mut section = Section::unlock(&arena, bytes.clone(), key).unwrap();
94 section.apply("Fuzz", &edit).unwrap();
95 let transaction = section.seal().unwrap().unwrap();
9496 let mut written = bytes.clone();
9597 transaction.apply(&mut written).unwrap();
9698 let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect();
......@@ -99,12 +101,7 @@ fuzz_target!(|data: &[u8]| {
99101 .windows(clear.len())
100102 .any(|w| w == clear)
101103 );
102 let (_, stored, _) = page(&written);
103 if styled {
104 assert_eq!(texts(&stored), texts(&expected));
105 } else {
106 assert!(stored.objects == expected.objects);
107 }
104 assert_eq!(texts(&page(&written, key).1), texts(&expected));
108105 bytes = written;
109106 }
110107});
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 = [
436436 "roxmltree",
437437 "swash",
438438 "wgpu",
439 "windows",
439440]
440441
441442[[package]]
platform/snow-leopard/probe/src/render.rs+2-2
......@@ -11,12 +11,12 @@ fn main() {
1111fn run() {
1212 let _context = context::current();
1313 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();
1515 let checker = scene::checker();
1616 let primitives = scene::primitives(&checker);
1717 let started = std::time::Instant::now();
1818 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();
2020 println!("drew in {:?}", started.elapsed());
2121 let file = std::fs::File::create("render-probe.png").unwrap();
2222 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() {
88 objc2::rc::autoreleasepool(|_| {
99 let _context = context::current();
1010 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();
1212 let ui = scene::interface(1.0);
1313 let layers = ui.layers();
1414 renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap();
1515 let file = std::fs::File::create("text-probe.png").unwrap();
1616 let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]);
1717 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();
1919 });
2020}
tools/RELEASE.md+27-5
......@@ -20,7 +20,7 @@ development builds, which never update themselves.
2020```text
2121latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}
22222026-09-29.r10/
23 build.json version, commit, and per platform: file, size, sha256, signature
23 build.json version, commit, changes, and per platform: file, size, sha256, signature
2424 build.json.sig ed25519 signature of build.json, hex
2525 Snowbound-2026-09-29-r10-macos-aarch64.zip
2626 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
3838was pointed to, and a forged pointer can only name another signed build, which
3939the 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
4162## Signing
4263
4364Every `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
90111```
91112
92113The script refuses to run unless the working copy matches `main` (a dry run
93only warns), runs Clippy (workspace, and `snowbound` without default features)
94and the workspace tests, builds each platform with
114only warns), gates the commit with `tools/ci.py` (see `tools/TESTING.md`),
115builds each platform with
95116`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6),
96117`crates/snowbound/linux/package.sh` (taking only its executables) and
97118`platform/windows/cargo.sh`, then checks
......@@ -137,8 +158,9 @@ signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
137158its own version. It stages the update beside the install, so the swap is a rename:
138159the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
139160executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
140Windows). The sync status icon gets a dot, and its popup offers
141Build Folder and Restart to Update.
161Windows). The sync status icon gets a dot, and its popup says what the update
162changes, unfolding to the titles, and offers Build Folder and Restart to
163Update; Check for Updates… lists them in its dialog.
142164
143165Restart to Update quits the app and starts the old executable with
144166`--finish-update`, which waits for the app to exit, renames the old bundle (or
tools/TESTING.md+43
......@@ -1,5 +1,47 @@
11# 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
345## Public fixtures
446
547From 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.
70112| Boundary | Entry point | Acceptance evidence |
71113| --- | --- | --- |
72114| 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`) |
73116| Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison |
74117| SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection |
75118| 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
5252
5353Launched 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
5557The `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
5759A 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]
4444TEAM = '9R7DPNW28H'
4545BUNDLE_ID = 'net.paperclover.snowbound'
4646CONTAINER = '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.
4848ONENOTE_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
5253def 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 @@
11"""Traversal of the exported document's referenced revisions."""
22import os
33from pathlib import Path
4import shutil
54import subprocess
65
76
87def _exporter():
9 """The document exporter built with protected sections, copied aside because a workspace
10 build without the feature overwrites target/debug/examples/document."""
8 """The document exporter, built for this run."""
119 if 'ONESTORE_DOCUMENT' in os.environ:
1210 return Path(os.environ['ONESTORE_DOCUMENT'])
1311 root = Path(__file__).resolve().parent.parent
14 subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--features', 'protected',
15 '--example', 'document'], cwd=root, check=True)
16 protected = root / 'target/debug/examples/document-protected'
17 shutil.copy2(root / 'target/debug/examples/document', protected)
18 return protected
12 subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--example', 'document'],
13 cwd=root, check=True)
14 return root / 'target/debug/examples/document'
1915
2016
2117EXPORTER = _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
66import json
77import os
88from pathlib import Path
9import re
910import shutil
1011import subprocess
1112import sys
......@@ -21,16 +22,14 @@ PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'lin
2122 'windows-x86_64', 'windows-aarch64']
2223# Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm.
2324WINDOWS = {'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]
2925# Clover's Developer ID Application certificate, by its SHA-1 hash: its name is the account
3026# holder's legal name, which nothing here prints or stores.
3127IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
3228# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.
3329NOTARY = 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
3635def derive(release, commits):
......@@ -79,9 +78,60 @@ def run(command, **kwargs):
7978 subprocess.run(command, cwd=ROOT, check=True, **kwargs)
8079
8180
82def commit_times(revset):
83 template = 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\n"'
84 return [datetime.fromisoformat(line) for line in jj('log', '--no-graph', '-r', revset, '-T', template).split()]
81def history():
82 """Every commit on `main` by id: its parents' ids, when it was made, and its message."""
83 template = ('commit_id ++ "\\x1f" ++ parents.map(|parent| parent.commit_id()).join(" ") ++ "\\x1f" ++ '
84 'committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S+00:00") ++ "\\x1f" ++ description ++ "\\x1e"')
85 commits = {}
86 for record in jj('log', '--no-graph', '-r', '::main', '-T', template).split('\x1e')[:-1]:
87 commit, parents, made, description = record.split('\x1f')
88 commits[commit] = (parents.split(), datetime.fromisoformat(made), description)
89 return commits
90
91
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
87137def sign(files):
......@@ -164,7 +214,8 @@ def main():
164214
165215 commit = jj('log', '--no-graph', '-r', 'main', '-T', 'commit_id').strip()
166216 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)
168219 print(f'Snowbound build {version[0]} revision {version[1]}, commit {commit}', flush=True)
169220
170221 published = Path(tempfile.mkdtemp(prefix='snowbound-release-')) if args.dry_run else PUBLISHED
......@@ -177,8 +228,7 @@ def main():
177228 sys.exit(f'{target} holds commit {build["commit"]}, not {commit}.')
178229 print(f'{target} is already published.')
179230 else:
180 for check in CHECKS:
181 run(check)
231 run([sys.executable, ROOT / 'tools/ci.py', '--rev', commit])
182232 clean(args.dry_run, 'after the checks')
183233 stage = ROOT / 'target/release-stage' / name(version)
184234 shutil.rmtree(stage, ignore_errors=True)
......@@ -208,6 +258,7 @@ def main():
208258 'version': name(version),
209259 'commit': commit,
210260 'published': datetime.now(ZONE).isoformat(timespec='seconds'),
261 'changes': changes(commits, commit),
211262 'archives': {platform: {
212263 'file': file.name,
213264 '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):
2222 self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10))
2323 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
2547 def test_latest_only_moves_forward(self):
2648 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}
2749 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):
376376 source = space['nodes'][source_page['children'][z]]
377377 if source['kind']['type'] == 'Ink':
378378 if child.tag == '{%s}InkDrawing' % ns['one']:
379 # Stroke coordinates are absolute; left-to-right pages report them
380 # relative to the canonical margin origin, right-to-left pages as stored.
379 # A drawing lies in the page's frame as outlines do: its strokes plus the
380 # offset a move gives it, reported from the canonical margin origin.
381381 extent = ink_extent(space, source)
382 # A moved drawing keeps its strokes and takes an offset.
383 extent[0] += source['layout']['x'] or 0.0
384 extent[1] += source['layout']['y'] or 0.0
385 if not source_page['kind']['rtl']:
386 for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))):
387 origin = source_page['kind']['margin_origin_' + axis]
388 extent[index] += canonical_origin - origin if origin is not None else 0
382 for index, axis in enumerate(('x', 'y')):
383 origin = source_page['kind']['margin_origin_' + axis]
384 canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4
385 extent[index] += (source['layout'][axis] or 0.0) + (canonical_origin - origin if origin is not None else 0)
389386 size = child.find('one:Size', ns)
390387 # Native sizes are one HIMETRIC unit larger than the point extent.
391388 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):
400397 origin = source_page['kind']['margin_origin_' + axis]
401398 canonical_origin = (-36.0 if source_page['kind']['rtl'] else 36.0) if axis == 'x' else 14.4
402399 projected = stored_position + (canonical_origin - origin if origin is not None else 0)
403 if axis == 'x' and source_page['kind']['rtl']:
404 assert source['kind']['type'] == 'Outline' and source['layout']['max_width'] is not None, 'RTL positioning requires an outline width'
400 if axis == 'x' and source_page['kind']['rtl'] and source['kind']['type'] == 'Outline':
405401 assert not any(p['id'] == 0x14001c84 for group in source['extra'] for p in group), 'Explicit RTL outline alignment requires a native control'
406 # Default RTL outlines keep their right edge when native layout changes width.
407 projected += source['layout']['max_width'] - float(child.find('one:Size', ns).get('width'))
402 # An outline laid out wider than its stored width keeps its right edge.
403 width = source['layout']['max_width']
404 projected -= max(0.0, float(child.find('one:Size', ns).get('width')) - width) if width is not None else 0.0
408405 if abs(projected - native_position) > 0.002:
409406 geometry.append({'file': str(relative), 'page': ordinal, 'z': z, 'axis': axis,
410407 'stored': stored_position, 'origin': origin, 'projected': projected, 'native': native_position})